diff --git a/Licenses/LICENSE.godottpd.md b/Licenses/LICENSE.godottpd.md new file mode 100644 index 0000000..2b717c4 --- /dev/null +++ b/Licenses/LICENSE.godottpd.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 deep Entertainment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mods/VRChatOSC/VRCFTParameters.gd b/Mods/VRChatOSC/VRCFTParameters.gd new file mode 100644 index 0000000..b9ccb65 --- /dev/null +++ b/Mods/VRChatOSC/VRCFTParameters.gd @@ -0,0 +1,1180 @@ +class_name ParameterMappings +extends Node + +enum COMBINATION_TYPE { + RANGE = 1, + COPY = 2, + AVERAGE = 3, + WEIGHTED = 4, + RANGE_AVERAGE = 5, + MAX = 6, + MIN = 7, + SUBTRACT = 8, + WEIGHTED_ADD = 9 +} +enum SHAPE_KEY_TYPE { + MEDIAPIPE = 1, + UNIFIED = 2 +} +enum DIRECTION { + POSITIVE = 1, + NEGATIVE = 2 +} + +static var simplified_parameter_mapping : Dictionary = { + "MouthFrown": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "MouthFrownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "MouthFrownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "MouthSmile": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "MouthSmileRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "MouthSmileLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "MouthStretch": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "MouthStretchRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "MouthStretchLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "EyeWide": { + "combination_type": COMBINATION_TYPE.MAX, + "combination_shapes": [ + { + "shape": "EyeWideLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeWideRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "EyeSquint": { + "combination_type": COMBINATION_TYPE.MAX, + "combination_shapes": [ + { + "shape": "EyeSquintLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeSquintRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "EyesSquint": { + "combination_type": COMBINATION_TYPE.MAX, + "combination_shapes": [ + { + "shape": "EyeSquintLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeSquintRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "BrowUpRight": { + "combination_type": COMBINATION_TYPE.WEIGHTED, + "combination_shapes": [ + { + "shape": "BrowOuterUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.6 + }, + { + "shape": "BrowInnerUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.4 + }, + ] + }, + "BrowUpLeft": { + "combination_type": COMBINATION_TYPE.WEIGHTED, + "combination_shapes": [ + { + "shape": "BrowOuterUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.6 + }, + { + "shape": "BrowInnerUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.4 + }, + ] + }, + "BrowUp": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "BrowUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "BrowUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "BrowDown": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "BrowDownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "BrowDownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "BrowOuterUp": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "BrowOuterUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "BrowOuterUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "BrowExpressionRight": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ + { + "shape": "BrowInnerUpRight" + }, + { + "shape": "BrowOuterUpRight" + } + ], + "negative": [ + { + "shape": "BrowDownRight" + } + ] + } + ] + }, + "BrowExpressionLeft": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ + { + "shape": "BrowInnerUpLeft" + }, + { + "shape": "BrowOuterUpLeft" + } + ], + "negative": [ + { + "shape": "BrowDownLeft" + } + ] + } + ] + }, + "BrowExpression": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "BrowExpressionRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "BrowExpressionLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + # Just group the already existing mediapipe keys. + "CheekSquint": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "CheekSquintLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "CheekSquintRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "MouthUpperX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "MouthUpperUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthUpperUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + } + ] + }, + "MouthUpperUp": { + "combination_type": COMBINATION_TYPE.WEIGHTED, + "combination_shapes": [ + { + "shape": "MouthUpperUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.5 + }, + { + "shape": "MouthUpperUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.5 + }, + ] + }, + # Again, group from existing keys. + "MouthLowerDown": { + "combination_type": COMBINATION_TYPE.WEIGHTED, + "combination_shapes": [ + { + "shape": "MouthLowerDownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.5 + }, + { + "shape": "MouthLowerDownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.5 + }, + ] + }, + "MouthOpen": { + "combination_type": COMBINATION_TYPE.WEIGHTED, + "combination_shapes": [ + { + "shape": "MouthUpperUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.25 + }, + { + "shape": "MouthUpperUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.25 + }, + { + "shape": "MouthLowerDownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.25 + }, + { + "shape": "MouthLowerDownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 0.25 + }, + ] + }, + "MouthX": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": true, + "positive": [{ + "shape": "MouthUpperRight", + }], + "negative": [{ + "shape": "MouthLowerRight" + }] + } + ] + }, + "JawX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "JawRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "JawLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "JawZ": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "JawForward", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "JawBackward", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "EyeLidRight": { + "combination_type": COMBINATION_TYPE.COPY, + "combination_shapes": [ + { + "shape": "EyeClosedRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "inverse": true + }, + { + "shape": "EyeLidRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "EyeLidLeft": { + "combination_type": COMBINATION_TYPE.COPY, + "combination_shapes": [ + { + "shape": "EyeClosedLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "inverse": true + }, + { + "shape": "EyeLidLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "EyeLid": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "EyeLidLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "EyeLidRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "MouthPress": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "MouthPressRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "MouthPressLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "MouthDimple": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "MouthDimplerRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "MouthDimplerLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + # Just group the existing mediapipe keys. + "NoseSneer": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "NoseSneerRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "NoseSneerLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "EyeRightX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookOutRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookInRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "RightEyeX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookOutRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookInRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "EyeRightY": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookUpRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookDownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "EyeLeftX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookInLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookOutLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "LeftEyeX": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookInLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookOutLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "EyeLeftY": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "EyeLookUpLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "EyeLookDownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "EyeX": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "EyeRightX", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeLeftX", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + ] + }, + "EyeY": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "EyeRightY", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeLeftY", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + ] + }, + "EyesY": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "EyeRightY", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "EyeLeftY", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + ] + }, +} +# These are mostly taken from the mapper here: +# https://github.com/benaclejames/VRCFaceTracking/blob/a4a66fcd7ee776b1740512a481aecac686224af0/VRCFaceTracking.Core/Params/Expressions/Legacy/Lip/UnifiedSRanMapper.cs +static var legacy_parameter_mapping : Dictionary = { + "SmileFrownRight": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "MouthSmileRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthFrownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + } + ] + }, + "SmileFrownLeft": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "MouthSmileLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthFrownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + } + ] + }, + "SmileFrown": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "SmileFrownRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "SmileFrownLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "SmileSadRight": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "MouthSmileRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthSadRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + } + ] + }, + "SmileSadLeft": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "MouthSmileLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthSadLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + } + ] + }, + "SmileSad": { + "combination_type": COMBINATION_TYPE.AVERAGE, + "combination_shapes": [ + { + "shape": "SmileSadRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "SmileSadLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "MouthApeShape": { + "combination_type": COMBINATION_TYPE.WEIGHTED_ADD, + "combination_shapes": [ + { + "shape": "MouthClosed", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 1.0 + } + ] + }, + "MouthSmileRight": { + "combination_type": COMBINATION_TYPE.MAX, + "combination_shapes": [ + { + "shape": "MouthSmileRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "MouthDimpleRight", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "MouthSmileLeft": { + "combination_type": COMBINATION_TYPE.MAX, + "combination_shapes": [ + { + "shape": "MouthSmileLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + }, + { + "shape": "MouthDimpleLeft", + "shape_type": SHAPE_KEY_TYPE.UNIFIED + } + ] + }, + "MouthLowerOverlay": { + "combination_type": COMBINATION_TYPE.WEIGHTED_ADD, + "combination_shapes": [ + { + "shape": "MouthRaiserLower", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "weight": 1.0 + } + ] + }, + + # -------- START SIMPLIFIED LEGACY PARAMETERS -------- + # These are done at the end of the mapping for the + # sole purpose of simplification of legacy parameters. + # -------- START SIMPLIFIED LEGACY PARAMETERS -------- + "JawOpenSuck": { + "combination_type": COMBINATION_TYPE.COPY, + "combination_shapes": [ + { + "shape": "JawOpen", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + { + "shape": "JawOpenSuck", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + }, + ] + }, + "JawOpenApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "JawOpen", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthApeShape", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "JawOpenForward": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "JawOpen", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "JawForward", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "JawOpenOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { + "shape": "JawOpen", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.POSITIVE + }, + { + "shape": "MouthLowerOverlay", + "shape_type": SHAPE_KEY_TYPE.UNIFIED, + "direction": DIRECTION.NEGATIVE + }, + ] + }, + "JawOpenPuff": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ + { + # Defaults to shape type unified. + "shape": "JawOpen" + } + ], + "negative": [ + { + "shape": "CheekPuffLeft" + }, + { + "shape": "CheekPuffRight" + } + ] + }, + ] + }, + "MouthUpperUpRightUpperInside": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpRight" }, + { "shape": "MouthUpperInside", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpRightPuffRight": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpRight" }, + { "shape": "CheekPuffRight", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpRightApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpRight" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpRightPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpRight" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpRightOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpRight" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpLeftUpperInside": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpLeft" }, + { "shape": "MouthUpperInside", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpLeftApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpLeft" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpLeftPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpLeft" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpLeftOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperUpLeft" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthUpperUpUpperInside": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "MouthUpperInside" } ] + } + ] + }, + "MouthUpperUpInside": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": true, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "MouthUpperInside" }, { "shape": "MouthLowerInside" } ] + } + ] + }, + "MouthUpperUpApe": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "MouthApeShape" } ] + } + ] + }, + "MouthUpperUpPout": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "MouthPout" } ] + } + ] + }, + "MouthUpperUpOverlay": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "MouthLowerOverlay" } ] + } + ] + }, + "MouthUpperUpSuck": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthUpperUpLeft" }, { "shape": "MouthUpperUpRight" } ], + "negative": [ { "shape": "CheekSuck" } ] + } + ] + }, + "MouthLowerDownRightLowerInside": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownRight" }, + { "shape": "MouthLowerInside", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownRightApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownRight" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownRightPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownRight" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownRightOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownRight" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownLeftLowerInside": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownLeft" }, + { "shape": "MouthLowerInside", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownLeftApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownLeft" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownLeftPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownLeft" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownLeftOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerDownLeft" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerDownLowerInside": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthLowerDownLeft" }, { "shape": "MouthLowerDownRight" } ], + "negative": [ { "shape": "MouthLowerInside" } ] + } + ] + }, + "MouthLowerDownInside": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": true, + "positive": [ { "shape": "MouthLowerDownLeft" }, { "shape": "MouthLowerDownRight" } ], + "negative": [ { "shape": "MouthUpperInside" }, { "shape": "MouthLowerInside" } ] + } + ] + }, + "MouthLowerDownApe": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthLowerDownLeft" }, { "shape": "MouthLowerDownRight" } ], + "negative": [ { "shape": "MouthApeShape" } ] + } + ] + }, + "MouthLowerDownPout": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthLowerDownLeft" }, { "shape": "MouthLowerDownRight" } ], + "negative": [ { "shape": "MouthPout" } ] + } + ] + }, + "MouthLowerDownOverlay": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthLowerDownLeft" }, { "shape": "MouthLowerDownRight" } ], + "negative": [ { "shape": "MouthLowerOverlay" } ] + } + ] + }, + "MouthUpperInsideOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthUpperInside" }, + { "shape": "MouthUpperOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "MouthLowerInsideOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthLowerInside" }, + { "shape": "MouthLowerOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileRightUpperOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileRight" }, + { "shape": "MouthUpperOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileRightLowerOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileRight" }, + { "shape": "MouthLowerOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileRightOverturn": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthUpperOverturn" }, { "shape": "MouthLowerOverturn" } ] + } + ] + }, + "SmileRightApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileRight" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileRightOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileRight" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileRightPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileRight" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileLeftUpperOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileLeft" }, + { "shape": "MouthUpperOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileLeftLowerOverturn": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileLeft" }, + { "shape": "MouthLowerOverturn", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileLeftOverturn": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" } ], + "negative": [ { "shape": "MouthUpperOverturn" }, { "shape": "MouthLowerOverturn" } ] + } + ] + }, + "SmileLeftApe": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileLeft" }, + { "shape": "MouthApeShape", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileLeftOverlay": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileLeft" }, + { "shape": "MouthLowerOverlay", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileLeftPout": { + "combination_type": COMBINATION_TYPE.RANGE, + "combination_shapes": [ + { "shape": "MouthSmileLeft" }, + { "shape": "MouthPout", "direction": ParameterMappings.DIRECTION.NEGATIVE } + ] + }, + "SmileUpperOverturn": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthUpperOverturn" } ] + } + ] + }, + "SmileLowerOverturn": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthLowerOverturn" } ] + } + ] + }, + "SmileOverturn": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthUpperOverturn" }, { "shape": "MouthLowerOverturn" } ] + } + ] + }, + "SmileApe": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthApeShape" } ] + } + ] + }, + "SmileOverlay": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthLowerOverlay" } ] + } + ] + }, + "SmilePout": { + "combination_type": COMBINATION_TYPE.RANGE_AVERAGE, + "combination_shapes": [ + { + "use_max_value": false, + "positive": [ { "shape": "MouthSmileLeft" }, { "shape": "MouthSmileRight" } ], + "negative": [ { "shape": "MouthPout" } ] + } + ] + }, +} diff --git a/Mods/VRChatOSC/VRCFTParameters.gd.uid b/Mods/VRChatOSC/VRCFTParameters.gd.uid new file mode 100644 index 0000000..a7d8e37 --- /dev/null +++ b/Mods/VRChatOSC/VRCFTParameters.gd.uid @@ -0,0 +1 @@ +uid://nmmdiw1kj11l diff --git a/Mods/VRChatOSC/VRCParam.gd b/Mods/VRChatOSC/VRCParam.gd new file mode 100644 index 0000000..dd0b55f --- /dev/null +++ b/Mods/VRChatOSC/VRCParam.gd @@ -0,0 +1,68 @@ +extends Node +class_name VRCParam + +var key : String +var type : String +var value : Variant +var full_path : String +var avatar_id : String +var param_binary : bool = false +var param_float : bool = false +var binary_key : String +var binary_exponent : int +var is_dirty : bool = false + +func _init(param_path : String, pkey : String, ptype : String, pavatar_id : String, pvalue) -> void: + full_path = param_path + key = pkey + type = ptype + value = pvalue + avatar_id = pavatar_id + param_binary = is_binary() + param_float = is_float() + if param_binary: + if key.ends_with("Negative"): + binary_key = key.replace("Negative", "") + # Negative exponent is "0". + binary_exponent = 0 + else: + if key.ends_with("128") or key.ends_with("256") or key.ends_with("512"): + binary_exponent = int(key.substr(len(key) - 3)) + binary_key = key.substr(0, len(key) - 3) + elif key.ends_with("16") or key.ends_with("32") or key.ends_with("64"): + binary_exponent = int(key.substr(len(key) - 2)) + binary_key = key.substr(0, len(key) - 2) + else: + binary_exponent = int(key.substr(len(key) - 1)) + binary_key = key.substr(0, len(key) - 1) + else: + # Helps with searching. + binary_key = key + +func is_binary() -> bool: + var key_name = key.ends_with("Negative") \ + or key.ends_with("1") \ + or key.ends_with("2") \ + or key.ends_with("4") \ + or key.ends_with("8") \ + or key.ends_with("16") \ + or key.ends_with("32") \ + or key.ends_with("64") \ + or key.ends_with("128") \ + or key.ends_with("256") \ + or key.ends_with("512") + var val_type = type == "T" + return key_name and val_type + +func is_float() -> bool: + return type == "f" + +func update_value(new_value : Variant) -> void: + value = new_value + is_dirty = true + +func reset_dirty() -> void: + is_dirty = false + +func to_osc() -> PackedByteArray: + return PackedByteArray() diff --git a/Mods/VRChatOSC/VRCParam.gd.uid b/Mods/VRChatOSC/VRCParam.gd.uid new file mode 100644 index 0000000..9092f63 --- /dev/null +++ b/Mods/VRChatOSC/VRCParam.gd.uid @@ -0,0 +1 @@ +uid://dg23esm6tfg5f diff --git a/Mods/VRChatOSC/VRCParams.gd b/Mods/VRChatOSC/VRCParams.gd new file mode 100644 index 0000000..e1e7821 --- /dev/null +++ b/Mods/VRChatOSC/VRCParams.gd @@ -0,0 +1,146 @@ +extends Node +class_name VRCParams + +var _params : Array[VRCParam] = [] +var _has_changed_avi : bool = false +var _avatar_id : String +var _raw_params : Dictionary +var _binary_params : Dictionary +var _float_params : Dictionary + +func reset(): + _params = [] + _has_changed_avi = false + _avatar_id = "" + _raw_params = {} + _float_params = {} + _binary_params = {} + +func initialize(raw_avatar_params : Dictionary, avatar_id : String, has_changed_avi : bool): + _raw_params = raw_avatar_params + _avatar_id = avatar_id + _has_changed_avi = has_changed_avi + + var orig_params = raw_avatar_params + + if raw_avatar_params.has("FT"): + raw_avatar_params = raw_avatar_params["FT"]["CONTENTS"] + _raw_params = raw_avatar_params + + if raw_avatar_params.has("v2"): + raw_avatar_params = raw_avatar_params["v2"]["CONTENTS"] + _raw_params = raw_avatar_params + + # Only if we are wanting to update/change param values do we progress here. + var param_names = raw_avatar_params.keys() + + # Catch-all fallback to the overall root avi parameters. + if len(param_names) == 0: + raw_avatar_params = orig_params + _raw_params = raw_avatar_params + param_names = raw_avatar_params.keys() + + for key in param_names: + # Verify this is a type/value parameter. + if not "TYPE" in raw_avatar_params[key] or not "VALUE" in raw_avatar_params[key]: + continue + # FIXME: Len of Value can actually be >0, or == 0. + var param = VRCParam.new( + raw_avatar_params[key]["FULL_PATH"], + key, + raw_avatar_params[key]["TYPE"], + avatar_id, + raw_avatar_params[key]["VALUE"][0] + ) + if param.param_binary: + if not _binary_params.has(param.binary_key): + _binary_params[param.binary_key] = [] + _binary_params[param.binary_key].append(param) + elif param.param_float: + assert(not _float_params.has(param.key), "Already existing float parameter with key %s" % param.key) + _float_params[param.key] = param + _params.append( + param + ) + pass + +func valid_params_from_dict(dict : Dictionary) -> Array[String]: + var keys = dict.keys() + var valid = _params.filter(func (p : VRCParam): return p.binary_key in keys) + var shapes : Array[String] = [] + for valid_param in valid: + shapes.append(valid_param.binary_key) + return shapes + +## Updates a particular key to the supplied value. +## This func takes care of the exchange between binary/float parameters in VRC tracking. +func update_value(key : String, value): + # TODO: Add cache for binary_key -> VRCParam. Make sure to reset in .reset method. + var params : Array[VRCParam] = _params.filter(func (p : VRCParam): return p.binary_key == key) + if len(params) == 0: + return + + var param : VRCParam = params[0] + + if param.is_binary(): + # This is actually an Array[VRCParam] but ... Godot... + var param_group : Array = _binary_params[param.binary_key] + + # Convert key to binary. + var is_neg = value < 0.0 + + # Important to normalize to positive. + var val_pos = absf(value) + + # Make sure we take care of neg. + var neg_params : Array = param_group.filter(func (p : VRCParam): return p.binary_exponent == 0) + if len(neg_params) == 1: + neg_params[0].update_value(is_neg) + + + param_group.sort_custom( + func (a : VRCParam, b : VRCParam): + return a.binary_exponent < b.binary_exponent + ) + + # 1. Determine N (number of magnitude bits) + var N : int = len(param_group.filter(func (p : VRCParam): return p.binary_exponent != 0)) + + # 2. Convert val_pos (0.0-1.0 float) to an integer representation (0 to 2^N - 1) + var integer_representation : int + if N == 0: + integer_representation = 0 + else: + # Scale val_pos to the range [0, 2^N]. Example N=3, range [0,8]. + var scaled_value : float = val_pos * pow(2.0, float(N)) + # Take the floor to get the discrete step. + integer_representation = floori(scaled_value) + # Clamp the integer_representation to be within [0, 2^N - 1]. + # (e.g. if N=3, max_val is 7). + var max_representable_int_val : int = int(pow(2.0, float(N))) - 1 + integer_representation = mini(integer_representation, max_representable_int_val) + integer_representation = maxi(integer_representation, 0) + + # 3. Set bits for each magnitude parameter + var num = 0 + for exp_param : VRCParam in param_group: + if exp_param.binary_exponent == 0: + continue + var bit : int = integer_representation & (1 << num) + exp_param.update_value(not bit == 0) + num += 1 + pass + elif param.is_float(): + param.update_value(value) + +## Get all parameters that have had values change since last use. +## Caller should reset is_dirty flag on the parameter after sending. +func get_dirty() -> Array[VRCParam]: + return _params.filter(func (p : VRCParam): return p.is_dirty) + +func get_all() -> Array[VRCParam]: + return _params + +func has(shape_key : String) -> bool: + var params : Array[VRCParam] = _params.filter(func (p : VRCParam): return p.binary_key == shape_key) + return len(params) > 0 diff --git a/Mods/VRChatOSC/VRCParams.gd.uid b/Mods/VRChatOSC/VRCParams.gd.uid new file mode 100644 index 0000000..d0fe21c --- /dev/null +++ b/Mods/VRChatOSC/VRCParams.gd.uid @@ -0,0 +1 @@ +uid://by0fxubrjwwlq diff --git a/Mods/VRChatOSC/VRChatFaceTracking.gd b/Mods/VRChatOSC/VRChatFaceTracking.gd new file mode 100644 index 0000000..a888e3a --- /dev/null +++ b/Mods/VRChatOSC/VRChatFaceTracking.gd @@ -0,0 +1,582 @@ +extends Mod_Base +class_name VRChatOSC + +@export var dns_service : MulticastDNS +@export var update_vrc_param_values : bool = false +@export var osc_client : KiriOSClient +@export var osc_query_server : OSCQueryServer +# User Settings +var send_eye_tracking : bool = true +var osc_server_listen_port : int = 9001: + get: + return osc_server_listen_port + set(value): + if osc_query_server != null: + osc_query_server.set_osc_server_port(osc_server_listen_port) + +var osc_query_server_listen_port : int = 61631: + get: + return osc_query_server_listen_port + set(value): + if osc_query_server != null: + osc_query_server.set_osc_query_server_port(osc_query_server_listen_port) + +# Internal fields +var osc_query_name : String = str(randi_range(500000, 5000000)) +var osc_server_name : String = str(randi_range(500000, 5000000)) +var vrchat_osc_query_endpoint : String = "" +## Cached processed keys that exist on the current avatar. +var cached_valid_keys : Array[String] = [] +## The current value of the avatar ID. +var current_avatar_id : String +## The previous value of the avatar ID. +var previous_avatar_id : String +## Parsed VRChat parameters. +var vrc_params : VRCParams = VRCParams.new() +## Keys for quick lookup and verification. +var vrc_param_keys : Array[String] = [] +var avatar_req : HTTPRequest +var client_send_rate_limit_ms : int = 500 +var curr_client_send_time : float +var processing_request : bool = false +# Can we not JUST USE THE SAME MAPPING +# WHY DOES EVERY APP NEED THEIR OWN WAY +var unified_to_arkit_mapping : Dictionary = { + "EyeLookUpRight": "eyeLookUpRight", + "EyeLookDownRight": "eyeLookDownRight", + "EyeLookInRight": "eyeLookInRight", + "EyeLookOutRight": "eyeLookOutRight", + "EyeLookUpLeft": "eyeLookUpLeft", + "EyeLookDownLeft": "eyeLookDownLeft", + "EyeLookInLeft": "eyeLookInLeft", + "EyeLookOutLeft": "eyeLookOutLeft", + "EyeClosedRight": "eyeBlinkRight", + "EyeClosedLeft": "eyeBlinkLeft", + "EyeSquintRight": "eyeSquintRight", + "EyeSquintLeft": "eyeSquintLeft", + "EyeWideRight": "eyeWideRight", + "EyeWideLeft": "eyeWideLeft", + "BrowDownRight": "browDownRight", + "BrowDownLeft": "browDownLeft", + "BrowInnerUp": "browInnerUp", + "BrowOuterUpRight": "browOuterUpRight", + "BrowOuterUpLeft": "browOuterUpLeft", + "NoseSneerRight": "noseSneerRight", + "NoseSneerLeft": "noseSneerLeft", + "CheekSquintRight": "cheekSquintRight", + "CheekSquintLeft": "cheekSquintLeft", + "CheekPuff": "cheekPuff", + "JawOpen": "jawOpen", + "MouthClosed": "mouthClose", + "JawRight": "jawRight", + "JawLeft": "jawLeft", + "JawForward": "jawForward", + "LipSuckUpper": "mouthRollUpper", + "LipSuckLower": "mouthRollLower", + "LipFunnel": "mouthFunnel", + "LipPucker": "mouthPucker", + "MouthUpperUpRight": "mouthUpperUpRight", + "MouthUpperUpLeft": "mouthUpperUpLeft", + "MouthLowerDownRight": "mouthLowerUpRight", + "MouthLowerDownLeft": "mouthLowerUpLeft", + "MouthSmileRight": "mouthSmileRight", + "MouthSmileLeft": "mouthSmileLeft", + "MouthFrownRight": "mouthFrownRight", + "MouthFrownLeft": "mouthFrownLeft", + "MouthStretchRight": "mouthStretchRight", + "MouthStretchLeft": "mouthStretchLeft", + "MouthDimplerRight": "mouthDimpleRight", + "MouthDimplerLeft": "mouthDimpleLeft", + "MouthRaiserUpper": "mouthShrugUpper", + "MouthRaiserLower": "mouthShrugLower", + "MouthPressRight": "mouthPressRight", + "MouthPressLeft": "mouthPressLeft", + "TongueOut": "tongueOut" +} +var arkit_to_unified_mapping : Dictionary = {} + +func _get_unified_value(shape : String, shape_type : ParameterMappings.SHAPE_KEY_TYPE, unified_blendshapes : Dictionary) -> float: + if shape_type == ParameterMappings.SHAPE_KEY_TYPE.UNIFIED: + return unified_blendshapes.get(shape, 0.0) + elif shape_type == ParameterMappings.SHAPE_KEY_TYPE.MEDIAPIPE: + var unified_shape: String = arkit_to_unified_mapping.get(shape, shape) + return unified_blendshapes.get(unified_shape, 0.0) + return 0.0 + +func _get_unified_shape(shape: String, shape_type: ParameterMappings.SHAPE_KEY_TYPE) -> String: + if shape_type == ParameterMappings.SHAPE_KEY_TYPE.UNIFIED: + return shape + elif shape_type == ParameterMappings.SHAPE_KEY_TYPE.MEDIAPIPE: + return arkit_to_unified_mapping.get(shape, shape) + return shape + +func _apply_transform_rules(unified_blendshapes : Dictionary, base_dict : Dictionary) -> void: + for param_name : String in base_dict.keys(): + var rule : Dictionary = base_dict[param_name] + var comb_type : int = rule["combination_type"] + var shapes : Array = rule["combination_shapes"] + + match comb_type: + ParameterMappings.COMBINATION_TYPE.COPY: + var src_shape_info : Dictionary = shapes[0] + var src_shape : String = src_shape_info["shape"] + var src_type : ParameterMappings.SHAPE_KEY_TYPE = src_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var src_value : float = _get_unified_value(src_shape, src_type, unified_blendshapes) + var src_inverse : bool = src_shape_info.get("inverse", false) + if src_inverse: + if src_value < 0: + src_value = abs(src_value) + else: + # We inverse the value from 1.0 + src_value = 1.0 - src_value + + for i in range(1, shapes.size()): + var dst_shape_info : Dictionary = shapes[i] + var dst_shape : String = dst_shape_info["shape"] + var dst_type : ParameterMappings.SHAPE_KEY_TYPE = dst_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var unified_shape : String = _get_unified_shape(dst_shape, dst_type) + unified_blendshapes[unified_shape] = src_value + + ParameterMappings.COMBINATION_TYPE.AVERAGE: + var sum : float = 0.0 + var count : int = 0 + for shape_info : Dictionary in shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + sum += value + count += 1 + unified_blendshapes[param_name] = sum / max(count, 1) + + ParameterMappings.COMBINATION_TYPE.RANGE_AVERAGE: + var positive_shapes : Array = shapes[0]["positive"] + var negative_shapes : Array = shapes[0]["negative"] + var use_max_value : bool = shapes[0].get("use_max_value", false) + if use_max_value: + var max_pos : float = 0.0 + var max_neg : float = 0.0 + for shape_info : Dictionary in positive_shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + if value > max_pos: + max_pos = value + for shape_info : Dictionary in negative_shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + if value > max_neg: + max_neg = value + unified_blendshapes[param_name] = max_pos + (max_neg * -1.0) + else: + var sum_pos : float = 0.0 + var sum_neg : float = 0.0 + for shape_info : Dictionary in positive_shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + sum_pos += value + for shape_info : Dictionary in negative_shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + sum_neg += value + unified_blendshapes[param_name] = (sum_pos / max(len(positive_shapes), 1)) + ((sum_neg * -1.0) / max(len(negative_shapes), 1)) + + ParameterMappings.COMBINATION_TYPE.RANGE: + var total : float = 0.0 + for shape_info : Dictionary in shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var direction : ParameterMappings.DIRECTION = shape_info.get("direction", ParameterMappings.DIRECTION.POSITIVE) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + if direction == ParameterMappings.DIRECTION.POSITIVE: + total += value + else: + total -= value + unified_blendshapes[param_name] = total + + ParameterMappings.COMBINATION_TYPE.WEIGHTED_ADD: + var total : float = 0.0 + for shape_info : Dictionary in shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var weight : float = shape_info.get("weight", 1.0) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + total += value * weight + unified_blendshapes[param_name] = total + + ParameterMappings.COMBINATION_TYPE.WEIGHTED: + var src_shape_info : Dictionary = shapes[0] + var src_shape : String = src_shape_info["shape"] + var src_type : ParameterMappings.SHAPE_KEY_TYPE = src_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var src_value : float = _get_unified_value(src_shape, src_type, unified_blendshapes) + var src_weight : float = src_shape_info["weight"] + + var dst_shape_info : Dictionary = shapes[1] + var dst_shape : String = dst_shape_info["shape"] + var dst_type : ParameterMappings.SHAPE_KEY_TYPE = dst_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var dst_value : float = _get_unified_value(dst_shape, dst_type, unified_blendshapes) + var dst_weight : float = dst_shape_info["weight"] + + unified_blendshapes[param_name] = src_value * src_weight + dst_value * dst_weight + + ParameterMappings.COMBINATION_TYPE.SUBTRACT: + var src_shape_info : Dictionary = shapes[0] + var src_shape : String = src_shape_info["shape"] + var src_type : ParameterMappings.SHAPE_KEY_TYPE = src_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var src_value : float = _get_unified_value(src_shape, src_type, unified_blendshapes) + + var dst_shape_info : Dictionary = shapes[1] + var dst_shape : String = dst_shape_info["shape"] + var dst_type : ParameterMappings.SHAPE_KEY_TYPE = dst_shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var dst_value : float = _get_unified_value(dst_shape, dst_type, unified_blendshapes) + + unified_blendshapes[param_name] = src_value - dst_value + + ParameterMappings.COMBINATION_TYPE.MAX: + var max_pos : float = 0.0 + for shape_info : Dictionary in shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + if value > max_pos: + max_pos = value + unified_blendshapes[param_name] = max_pos + + ParameterMappings.COMBINATION_TYPE.MIN: + var min_num : float = 1.1 # Very unlikely > 1.0 exists given they're constrainted to 1.0 + for shape_info : Dictionary in shapes: + var shape : String = shape_info["shape"] + var shape_type : ParameterMappings.SHAPE_KEY_TYPE = shape_info.get("shape_type", ParameterMappings.SHAPE_KEY_TYPE.UNIFIED) + var value : float = _get_unified_value(shape, shape_type, unified_blendshapes) + if value < min_num: + min_num = value + unified_blendshapes[param_name] = min_num + +func scene_init() -> void: + print("[VRChat Face Tracking] Starting services and requesting avatar parameters...") + if not osc_query_server.running: + osc_query_server.start() + if not dns_service.running: + dns_service.start() + previous_avatar_id = "" + _get_avatar_params() + +func scene_shutdown() -> void: + print("[VRChat Face Tracking] Stopping servers...") + osc_query_server.stop() + dns_service.stop() + +func _ready() -> void: + avatar_req = HTTPRequest.new() + add_child(avatar_req) + avatar_req.request_completed.connect(_avatar_params_request_complete) + # We need to know the vrc endpoint to get data from. + dns_service.on_receive.connect(_vrc_dns_packet) + # We need to have another connection to resolve OTHER DNS queries (OSCQuery). + dns_service.on_receive.connect(_resolve_dns_packet) + osc_query_server.osc_paths = { + "/avatar/change": { + "DESCRIPTION": "Avatar Change", + "FULL_PATH": "/avatar/change", + "ACCESS": 2, # WRITE_ONLY + "TYPE": "s", + } + } + osc_query_server.on_osc_server_message_received.connect(_osc_query_received) + + for key in unified_to_arkit_mapping: + var new_key = unified_to_arkit_mapping[key] + var new_value = key + arkit_to_unified_mapping[new_key] = new_value + + add_tracked_setting("send_eye_tracking", "Send eye tracking blendshapes") + add_tracked_setting("osc_query_server_listen_port", "OSC Query Server Listen Port") + add_tracked_setting("osc_server_listen_port", "OSC Server Listen Port") + + var force_avatar_detection_button : Button = Button.new() + force_avatar_detection_button.text = "Force Avatar Refresh" + force_avatar_detection_button.pressed.connect( + func(): + previous_avatar_id = "" + _get_avatar_params() + update_settings_ui()) + get_settings_window().add_child(force_avatar_detection_button) + + update_settings_ui() + +func _process(delta : float) -> void: + if vrchat_osc_query_endpoint == "": + return + + curr_client_send_time += delta + if curr_client_send_time > int(client_send_rate_limit_ms) / 1000: + curr_client_send_time = 0 + + # Map the blendshapes we have from mediapipe to the unified versions. + var unified_blendshapes : Dictionary = _map_blendshapes_to_unified() + + # Apply unified blendshape simplification mapping + _apply_transform_rules(unified_blendshapes, ParameterMappings.simplified_parameter_mapping) + + if unified_blendshapes.has("MouthStretchRight") and unified_blendshapes.has("MouthStretchLeft") \ + and unified_blendshapes.has("MouthSmileRight") and unified_blendshapes.has("MouthSmileLeft"): + # Set MouthSadLeft/Right - complexish conversions + unified_blendshapes["MouthSadRight"] = \ + maxf(0, \ + unified_blendshapes["MouthFrown"] > unified_blendshapes["MouthStretchRight"] + if unified_blendshapes["MouthFrown"] \ + else unified_blendshapes["MouthStretchRight"] - unified_blendshapes["MouthSmileRight"]) + unified_blendshapes["MouthSadLeft"] = \ + maxf(0, \ + unified_blendshapes["MouthFrown"] > unified_blendshapes["MouthStretchLeft"] + if unified_blendshapes["MouthFrown"] \ + else unified_blendshapes["MouthStretchLeft"] - unified_blendshapes["MouthSmileLeft"]) + + if unified_blendshapes.has("EyeWideLeft") \ + and unified_blendshapes.has("EyeWideRight") \ + and unified_blendshapes.has("EyeLidLeft") \ + and unified_blendshapes.has("EyeLidRight") \ + and unified_blendshapes.has("EyeSquintLeft") \ + and unified_blendshapes.has("EyeSquintRight"): + # Complex calcs separated out for simplicity. + # This ends up as Left/RightEyeLidExpandedSqueeze. + _calc_eyelid_expanded_squeeze(unified_blendshapes, "Left", "EyeWideLeft", "EyeLidLeft", "EyeSquintLeft") + _calc_eyelid_expanded_squeeze(unified_blendshapes, "Right", "EyeWideRight", "EyeLidRight", "EyeSquintRight") + + # Apply legacy parameter mapping (this makes me sad) + _apply_transform_rules(unified_blendshapes, ParameterMappings.legacy_parameter_mapping) + + var tracker_dict : Dictionary = get_global_mod_data("trackers") + if tracker_dict.has("head"): + var head_basis : Basis = tracker_dict["head"].transform.basis + var basic_rot = head_basis.get_euler() + unified_blendshapes["HeadRotationX"] = -basic_rot.y + unified_blendshapes["HeadRotationY"] = -basic_rot.x + unified_blendshapes["HeadRotationZ"] = basic_rot.z + + if len(cached_valid_keys) == 0: + cached_valid_keys = vrc_params.valid_params_from_dict(unified_blendshapes) + + # Set params to values + for shape : String in unified_blendshapes: + if not shape in cached_valid_keys: + continue + if not send_eye_tracking and shape.contains("Eye"): + continue + vrc_params.update_value(shape, unified_blendshapes[shape]) + # Finally, send all dirty params off to VRC + _send_dirty_params() + +func _calc_squeeze(bs: Dictionary, wide_key: String, lid_key: String, squint_key: String) -> float: + var wide = bs[wide_key] + var lid = bs[lid_key] + var squint = bs[squint_key] + return wide * 0.2 + (lid * 0.8) - (1.0 - pow(lid, 0.15) * squint) + +func _calc_eyelid_expanded_squeeze(bs: Dictionary, side: String, wide_key: String, lid_key: String, squint_key: String) -> void: + var value = _calc_squeeze(bs, wide_key, lid_key, squint_key) + var target_key = side + "EyeLidExpandedSqueeze" + if value > 0.8: + bs[target_key] = bs[wide_key] + elif value >= 0.0: + bs[target_key] = bs[lid_key] + else: + bs[target_key] = (1.0 - pow(bs[lid_key], 0.15) * bs[squint_key]) + +func _map_blendshapes_to_unified() -> Dictionary: + var blendshapes : Dictionary = get_global_mod_data("BlendShapes") + var unified_blendshapes : Dictionary = {} + for blendshape in blendshapes: + if not arkit_to_unified_mapping.has(blendshape): + continue + var unified_blendshape = arkit_to_unified_mapping[blendshape] + unified_blendshapes[unified_blendshape] = blendshapes[blendshape] + + return unified_blendshapes + +func _send_dirty_params(): + var to_send_osc : Array[VRCParam] = vrc_params.get_dirty() + var bundle : Array = [] + + for param in to_send_osc: + param.reset_dirty() + # We send the message with the full path for the avatar parameter, and type. + var type = param.type + if param.type == "T": + # Param value is true? Send as type "T" representing "True" in OSC. + if param.value: + type = "T" + else: + type = "F" + bundle.append(osc_client.prepare_osc_message(param.full_path, type, [param.value])) + + var send = osc_client.create_osc_bundle(3535, bundle) + osc_client.send_osc_message_raw(send) + +func _osc_query_received(address : String, _args) -> void: + if address == "/avatar/change": + print("[VRChat Face Tracking] Avatar change detected via OSC Query Server.") + _get_avatar_params() + +func _resolve_dns_packet(packet : DNSPacket, _raw_packet : StreamPeerBuffer) -> void: + if vrchat_osc_query_endpoint == "" or packet.opcode != 0: + return + + for question : DNSQuestion in packet.dns_questions: + # We have two services to respond to: + # 1. The OSC Query Server (http) (_oscjson._tcp.local) + # 2. The OSC Server (udp) (_osc._udp.local) + var service_name : String = "" + var is_osc_query : bool = false + if question.full_label.begins_with("_osc._udp.local"): + service_name = "SNEKS-" + osc_server_name + elif question.full_label.begins_with("_oscjson._tcp.local"): + service_name = "SNEKS-" + osc_query_name + is_osc_query = true + + if service_name == "": + continue + + var full_name : Array[String] = [service_name, question.labels[0], question.labels[1], question.labels[2]] + var full_service_name : Array[String] = [service_name, question.labels[0].replace("_", ""), question.labels[1].replace("_", "")] + + var txt_record = DNSRecord.new() + + txt_record.labels = full_name + txt_record.dns_type = DNSRecord.RECORD_TYPE.TXT + txt_record.data = { "text": "txtvers=1" } + + var srv_record = DNSRecord.new() + srv_record.labels = full_name + srv_record.dns_type = DNSRecord.RECORD_TYPE.SRV + if is_osc_query: + srv_record.data = { "port": osc_query_server.http_server.port } + else: + srv_record.data = { "port": osc_query_server.osc_server_port } + srv_record.data.set("target", full_service_name) + + var a_record = DNSRecord.new() + a_record.dns_type = DNSRecord.RECORD_TYPE.A + a_record.labels = full_service_name + # We know this will always be 127.0.0.1 buuuuuuuuut + if is_osc_query: + a_record.data = { "address": osc_query_server.http_server.bind_address } + else: + a_record.data = { "address": osc_query_server.osc_server_ip } + + var ptr_record = DNSRecord.new() + ptr_record.dns_type = DNSRecord.RECORD_TYPE.PTR + ptr_record.data = { "domain_labels": full_name } + ptr_record.labels = question.labels + + var answers : Array[DNSRecord] = [ptr_record] + var additional : Array[DNSRecord] = [txt_record, srv_record, a_record] + + var new_packet = DNSPacket.new() + new_packet.dns_answers = answers + new_packet.dns_additional = additional + new_packet.query_response = true + new_packet.conflict = true + new_packet.tentative = false + new_packet.truncation = false + new_packet.opcode = 0 + new_packet.response_code = 0 + new_packet.id = 0 + + # Send it off to our peers to alert them to the answer. + dns_service.send_packet(new_packet) + +func _vrc_dns_packet(packet : DNSPacket, _raw_packet : StreamPeerBuffer) -> void: + if not packet.query_response: + return + if len(packet.dns_answers) == 0 or len(packet.dns_additional) == 0: + return + + var ptr_record : DNSRecord = packet.dns_answers[0] + if ptr_record.dns_type != DNSRecord.RECORD_TYPE.PTR: + return + + if ptr_record.full_label != "_oscjson._tcp.local": + return + + var domain_label : String = ptr_record.data["full_label"] + if not domain_label.begins_with("VRChat-Client") \ + and not domain_label.begins_with("ChilloutVR-GameClient"): + return + + var a_records : Array[DNSRecord] = packet.dns_additional.filter( + func (x : DNSRecord) -> bool: return x.dns_type == DNSRecord.RECORD_TYPE.A + ) + if len(a_records) == 0: + return + var srv_records : Array[DNSRecord] = packet.dns_additional.filter( + func (x : DNSRecord) -> bool: return x.dns_type == DNSRecord.RECORD_TYPE.SRV + ) + if len(srv_records) == 0: + return + var ip_address : String = a_records[0].data["address"] + var port : int = srv_records[0].data["port"] + vrchat_osc_query_endpoint = "http://%s:%s" % \ + [ + ip_address, + port + ] + + if not osc_client.is_client_active(): + # Init osc sender. Default to 9000 (default OSC port). + osc_client.change_port_and_ip(9000, ip_address) + osc_client.start_client() + # If it is the first time going through, we get the current avi params. + _get_avatar_params() + + print("[VRChat Face Tracking] Found VRChat OSC Query Endpoint: %s" % vrchat_osc_query_endpoint) + +func _get_avatar_params(): + if vrchat_osc_query_endpoint == "": + return + if processing_request: + return + var err = avatar_req.request(vrchat_osc_query_endpoint + "/avatar") + processing_request = true + if err != OK: + printerr("[VRChat Face Tracking] Failed to request VRC avatar parameters with error code: %d" % err) + +func _avatar_params_request_complete(result : int, response_code : int, + _headers: PackedStringArray, body: PackedByteArray) -> void: + processing_request = false + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + printerr("[VRChat Face Tracking] Request for VRC avatar params failed.") + return + print("[VRChat Face Tracking] Avatar param request complete.") + + var json = JSON.parse_string(body.get_string_from_utf8()) + var root_contents : Dictionary = json["CONTENTS"] + if not root_contents.has("parameters") or not root_contents.has("change"): + # Could be booting game/loading/logging in/not in game. + printerr("[VRChat Face Tracking] No parameters, or avatar information exists.") + return + + # Uh oh... that's a lot of hardcoded values. + # FIXME: Check that all these keys exist. + current_avatar_id = json["CONTENTS"]["change"]["VALUE"][0] + var has_changed_avi : bool = current_avatar_id != previous_avatar_id + if has_changed_avi: + # Update only if changed avi. + print("[VRChat Face Tracking] Avatar has changed. Updating parameter keys, values and types.") + vrc_param_keys = [] + cached_valid_keys = [] + vrc_params.reset() + + # We always pull raw avatar params to update the current value. + var raw_avatar_params = json["CONTENTS"]["parameters"]["CONTENTS"] + + if not update_vrc_param_values and not has_changed_avi: + previous_avatar_id = current_avatar_id + return + + vrc_params.initialize(raw_avatar_params, current_avatar_id, has_changed_avi) + + previous_avatar_id = current_avatar_id diff --git a/Mods/VRChatOSC/VRChatFaceTracking.gd.uid b/Mods/VRChatOSC/VRChatFaceTracking.gd.uid new file mode 100644 index 0000000..e472d3a --- /dev/null +++ b/Mods/VRChatOSC/VRChatFaceTracking.gd.uid @@ -0,0 +1 @@ +uid://ysgfrvghy1n5 diff --git a/Mods/VRChatOSC/VRChatFaceTracking.tscn b/Mods/VRChatOSC/VRChatFaceTracking.tscn new file mode 100644 index 0000000..d1b4138 --- /dev/null +++ b/Mods/VRChatOSC/VRChatFaceTracking.tscn @@ -0,0 +1,30 @@ +[gd_scene load_steps=6 format=3 uid="uid://cpe3ulnjnrapo"] + +[ext_resource type="Script" uid="uid://ysgfrvghy1n5" path="res://Mods/VRChatOSC/VRChatFaceTracking.gd" id="1_md8v4"] +[ext_resource type="Script" uid="uid://bfemeu7ysbxwc" path="res://Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd" id="2_xkeea"] +[ext_resource type="Script" uid="uid://goefhxca5k8g" path="res://Mods/VMCController/KiriOSC/KiriOSCClient.gd" id="3_435n5"] +[ext_resource type="Script" uid="uid://bg1sitssmsggs" path="res://Mods/VRChatOSC/osc-query/OSCQueryServer.gd" id="4_shth5"] +[ext_resource type="Script" uid="uid://csrri2vhxv4w5" path="res://Mods/VMCController/KiriOSC/KiriOSCServer.gd" id="5_k104g"] + +[node name="VRChatFaceTracking" type="Node3D" node_paths=PackedStringArray("dns_service", "osc_client", "osc_query_server")] +script = ExtResource("1_md8v4") +dns_service = NodePath("MulticastDNS") +osc_client = NodePath("KiriOSClient") +osc_query_server = NodePath("OSCQueryServer") + +[node name="MulticastDNS" type="Node" parent="."] +script = ExtResource("2_xkeea") +metadata/_custom_type_script = "uid://bfemeu7ysbxwc" + +[node name="KiriOSClient" type="Node" parent="."] +script = ExtResource("3_435n5") +metadata/_custom_type_script = "uid://goefhxca5k8g" + +[node name="OSCQueryServer" type="Node" parent="." node_paths=PackedStringArray("osc_server")] +script = ExtResource("4_shth5") +osc_server = NodePath("../KiriOSCServer") +metadata/_custom_type_script = "uid://bg1sitssmsggs" + +[node name="KiriOSCServer" type="Node" parent="."] +script = ExtResource("5_k104g") +metadata/_custom_type_script = "uid://csrri2vhxv4w5" diff --git a/Mods/VRChatOSC/description.txt b/Mods/VRChatOSC/description.txt new file mode 100644 index 0000000..133d82e --- /dev/null +++ b/Mods/VRChatOSC/description.txt @@ -0,0 +1,5 @@ +Implements sending of VRCFT-compatible parameters based on tracking to a locally running VRChat instance. + +Your VRChat avatar will need compatible parameters implemented prior to using this mod (i.e. /FT/v2/...). + +This mod also adds support for head tracking, specifically sending HeadRotationX, Y, Z as float parameters. diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd b/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd new file mode 100644 index 0000000..1ea71f6 --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd @@ -0,0 +1,98 @@ +extends Node +class_name DNSPacket + +## Unsigned Short - ID +var id : int +## QUERYRESPONSE +var query_response : bool +## Integer - OPCODE +var opcode : int +## CONFLICT +var conflict : bool +## TRUNCATION +var truncation : bool +## TENTATIVE +var tentative : bool +## Integer - RESPONSECODE +var response_code : int + +## DNS Questions +var dns_questions : Array[DNSQuestion] = [] +## DNS Answers (if any) +var dns_answers : Array[DNSRecord] = [] +## DNS Authoritories +var dns_authoritories : Array[DNSRecord] = [] +## DNS Additional +var dns_additional : Array[DNSRecord] = [] + +static func from_packet(packet : StreamPeerBuffer) -> DNSPacket: + var dns_packet : DNSPacket = DNSPacket.new() + + dns_packet.id = packet.get_u16() + var flags = packet.get_u16() + dns_packet.response_code = (flags & 0x000F); + dns_packet.tentative = (flags & 0x0100) == 0x0100; + dns_packet.truncation = (flags & 0x0200) == 0x0200; + dns_packet.conflict = (flags & 0x0400) == 0x0400; + dns_packet.opcode = (flags & 0x7800) >> 11; + dns_packet.query_response = (flags & 0x8000) == 0x8000 + + var cache : Dictionary = {} + + # Fill in the extra properties based on lengths read from packet. + var question_length = packet.get_u16() + var answer_length = packet.get_u16() + var auth_length = packet.get_u16() + var add_length = packet.get_u16() + for i in range(question_length): + dns_packet.dns_questions.append(DNSQuestion.from_packet(packet, cache)) + for i in range(answer_length): + dns_packet.dns_answers.append(DNSRecord.from_packet(packet, cache)) + for i in range(auth_length): + dns_packet.dns_authoritories.append(DNSRecord.from_packet(packet, cache)) + for i in range(add_length): + dns_packet.dns_additional.append(DNSRecord.from_packet(packet, cache)) + + return dns_packet + +## To raw byte packet for sending. +func to_packet() -> StreamPeerBuffer: + var packet := StreamPeerBuffer.new() + packet.big_endian = true + packet.put_u16(id) + + var flags := 0 + flags |= (response_code & 0xF) + if tentative: flags |= 0x0100 + if truncation: flags |= 0x0200 + if conflict: flags |= 0x0400 + flags |= ((opcode & 0xF) << 11) + if query_response: + flags |= 0x8000 + packet.put_u16(flags) + + # qdcount, ancount, nscount, arcount + packet.put_u16(dns_questions.size()) + packet.put_u16(dns_answers.size()) + packet.put_u16(dns_authoritories.size()) + packet.put_u16(dns_additional.size()) + + # Prepare a cache for name compression: domain_name -> packet offset + var cache: Dictionary = {} + + # Serialize questions + for question : DNSQuestion in dns_questions: + question.to_packet(packet, cache) + + # Serialize answers + for answer : DNSRecord in dns_answers: + answer.to_packet(packet, cache) + + for auth : DNSRecord in dns_authoritories: + auth.to_packet(packet, cache) + + for add : DNSRecord in dns_additional: + add.to_packet(packet, cache) + + packet.seek(0) + return packet diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd.uid b/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd.uid new file mode 100644 index 0000000..de80aaf --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSPacket.gd.uid @@ -0,0 +1 @@ +uid://cwfmaaoc0u1ig diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd b/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd new file mode 100644 index 0000000..a2ed645 --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd @@ -0,0 +1,80 @@ +extends Node +class_name DNSQuestion +## Unsigned Short - Type of question/record. +var dns_type : int +## Unsigned Short - The question/record class. +var dns_class : int +## Labels for the question +var labels : Array[String] = [] +## The full label is just the labels joined. +var full_label : String +## The cache MUST be the same across the entire packet deserialization. +var _cache : Dictionary + +## Extract a DNS Question from the provided packet with the label cache. +static func from_packet(packet : StreamPeerBuffer, cache: Dictionary) -> DNSQuestion: + var dns_question : DNSQuestion = DNSQuestion.new() + + dns_question._cache = cache + dns_question.labels = dns_question._read_labels(packet) + dns_question.full_label = ".".join(dns_question.labels) + dns_question.dns_type = packet.get_u16() + dns_question.dns_class = packet.get_u16() + + return dns_question + +## Extract a DNS Question from the provided packet with the label cache, applying to the record. +static func from_packet_for_record(packet : StreamPeerBuffer, cache: Dictionary, record : DNSRecord): + record._cache = cache + record.labels = record._read_labels(packet) + record.full_label = ".".join(record.labels) + record.dns_type = packet.get_u16() + record.dns_class = packet.get_u16() + +## Writes the current DNS Question to the packet. +func to_packet(packet: StreamPeerBuffer, cache: Dictionary): + _write_labels(packet, labels, cache) + packet.put_u16(dns_type) + packet.put_u16(dns_class) + +## Recursively read all labels +func _read_labels(packet : StreamPeerBuffer) -> Array[String]: + var pos = packet.get_position() + var length = packet.get_u8() + # Check if compressed. + if length & 0xC0 == 0xC0: + var pointer = (length ^ 0xC0) << 8 | packet.get_u8() + var cname = _cache[pointer] + _cache[pos] = cname + return cname + var inner_labels : Array[String] = [] + if length == 0: + return inner_labels + + # Get data returns a record of the attempt, and the results from the attempt. + var raw_data = packet.get_data(length)[1] + var packed_data = PackedByteArray(raw_data) + + inner_labels.append(packed_data.get_string_from_utf8()) + inner_labels.append_array(_read_labels(packet)) + _cache[pos] = inner_labels + + return inner_labels + +func _write_labels(packet: StreamPeerBuffer, cur_labels: Array[String], cache: Dictionary) -> void: + var i = 0 + while i < cur_labels.size(): + var suffix = ".".join(cur_labels.slice(i)) + if cache.has(suffix): + var ptr = cache[suffix] + packet.put_u8(0xC0 | (ptr >> 8)) + packet.put_u8(ptr & 0xFF) + return + else: + cache[suffix] = packet.get_position() + var label = cur_labels[i] + var raw = label.to_utf8_buffer() + packet.put_u8(raw.size()) + packet.put_data(raw) + i += 1 + packet.put_u8(0) diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd.uid b/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd.uid new file mode 100644 index 0000000..2ada4d1 --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSQuestion.gd.uid @@ -0,0 +1 @@ +uid://d25h1nb2rddvb diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd b/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd new file mode 100644 index 0000000..d481bf6 --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd @@ -0,0 +1,132 @@ +class_name DNSRecord +extends DNSQuestion + +enum RECORD_TYPE { + A = 1, + NS = 2, + PTR = 12, + TXT = 16, + SRV = 33 +} + +## Time-to-live in seconds +var ttl_seconds : int +## Length of data +var length : int +## Structured data (changes depending on record_type) +var data : Dictionary + +## Extract a DNS Record from the provided packet with the label cache. +static func from_packet(packet : StreamPeerBuffer, cache: Dictionary) -> DNSRecord: + # Make sure we init the details of the packet. + var dns_record : DNSRecord = DNSRecord.new() + DNSQuestion.from_packet_for_record(packet, cache, dns_record) + + dns_record.ttl_seconds = packet.get_u32() + dns_record.length = packet.get_u16() + + if dns_record.dns_type == RECORD_TYPE.A: + dns_record._a_record(packet) + elif dns_record.dns_type == RECORD_TYPE.PTR: + dns_record._ptr_record(packet) + elif dns_record.dns_type == RECORD_TYPE.SRV: + dns_record._srv_record(packet) + elif dns_record.dns_type == RECORD_TYPE.NS: + dns_record._ns_record(packet) + elif dns_record.dns_type == RECORD_TYPE.TXT: + dns_record._txt_record(packet) +# else: +# print("Unsupported DNS record type found: ", dns_record.dns_type) + + return dns_record + +func to_packet(packet: StreamPeerBuffer, cache: Dictionary) -> void: + super.to_packet(packet, cache) + packet.put_u32(ttl_seconds) + var rdlength_offset = packet.get_position() + packet.put_u16(0) + var rdata_start = packet.get_position() + match dns_type: + RECORD_TYPE.A: + # IPv4: 4 octets + # data["address"] is a string "x.x.x.x" + var parts = data["address"].split(".") + for p in parts: + packet.put_u8(int(p)) + + RECORD_TYPE.PTR: + super._write_labels(packet, data["domain_labels"], cache) + + RECORD_TYPE.NS: + super._write_labels(packet, data["authority"], cache) + + RECORD_TYPE.SRV: + # priority, weight, port, target + packet.put_u16(data.get("priority", 1)) + packet.put_u16(data.get("weight", 1)) + packet.put_u16(data["port"]) + # target is a domain name (labels array) + var srv_q = DNSQuestion.new() + srv_q.labels = data["target"] + srv_q.dns_type = 0 + srv_q.dns_class = 0 + srv_q.to_packet(packet, cache) + + RECORD_TYPE.TXT: + var txt = data["text"] + var pos = 0 + while pos < txt.length(): + var chunk_size = min(255, txt.length() - pos) + var chunk = txt.substr(pos, chunk_size) + var raw = chunk.to_utf8_buffer() + packet.put_u8(raw.size()) + packet.put_data(raw) + pos += chunk_size + _: + push_error("Unsupported RDATA serialization for type %d" % dns_type) + + var rdata_end = packet.get_position() + var rdlength = rdata_end - rdata_start + + # go back and patch + var cur = packet.get_position() + packet.seek(rdlength_offset) + packet.put_u16(rdlength) + packet.seek(cur) + +func _a_record(packet : StreamPeerBuffer) -> void: + data["address"] = _get_ipv4_address(packet) + +func _ptr_record(packet : StreamPeerBuffer) -> void: + data["domain_labels"] = _read_labels(packet) + data["full_label"] = ".".join(data["domain_labels"]) + +func _srv_record(packet : StreamPeerBuffer) -> void: + data["priority"] = packet.get_u16() + data["weight"] = packet.get_u16() + data["port"] = packet.get_u16() + data["target"] = _read_labels(packet) + +func _ns_record(packet : StreamPeerBuffer) -> void: + data["authority"] = _read_labels(packet) + +func _txt_record(packet : StreamPeerBuffer) -> void: + data["text"] = "" + var l = length + while l > 0: + var part_length : int = packet.get_u8() + var part : PackedByteArray = PackedByteArray(packet.get_data(part_length)[1]) + var str_part : String = part.get_string_from_ascii() + data["text"] += str_part + l -= part_length + 1 # We +1 here for the part length byte we read. + +func _get_ipv4_address(packet : StreamPeerBuffer) -> String: + # WHY + var ip = packet.get_u32() + var ip_bytes : Array[int] = [0, 0, 0, 0] + ip_bytes[0] = int((ip >> 24) & 0xFF) + ip_bytes[1] = int((ip >> 16) & 0xFF) + ip_bytes[2] = int((ip >> 8) & 0XFF) + ip_bytes[3] = int(ip & 0xFF) + + return "%d.%d.%d.%d" % [ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]] diff --git a/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd.uid b/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd.uid new file mode 100644 index 0000000..829601d --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/DNSRecord.gd.uid @@ -0,0 +1 @@ +uid://cj77u4poxfa1x diff --git a/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd b/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd new file mode 100644 index 0000000..7108e56 --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd @@ -0,0 +1,68 @@ +extends Node +class_name MulticastDNS + +var server : UDPServer +var clients : Array[PacketPeerUDP] = [] +var multicast_address : String = "224.0.0.251" +var local_addresses : Array[String] = [] +var running : bool = false +signal on_receive(packet : DNSPacket, raw_packet : StreamPeerBuffer) + +func _ready() -> void: + start() + +func start() -> void: + running = true + server = UDPServer.new() + # We only listen on ipv4, we're not using mDNS for IPv6. + var err = server.listen(5353, "0.0.0.0") + if err != OK: + printerr("[Multicast DNS] Failed to start listening on port 5353 with error code %d" % err) + +func stop() -> void: + server.stop() + running = false + +func _process(_delta : float) -> void: + server.poll() # Important! + if server.is_connection_available(): + var receiver = server.take_connection() + for interface_details : Dictionary in IP.get_local_interfaces(): + if not receiver.is_bound(): + receiver.join_multicast_group(multicast_address, interface_details["name"]) + # TODO: Make sender sockets for each local interface to support sending. + for ip_addr in interface_details["addresses"]: + if local_addresses.has(ip_addr): + continue + local_addresses.append(ip_addr) + clients.append(receiver) + + for receiver in clients: + if receiver.get_available_packet_count() <= 0: + continue + + var packet_bytes : PackedByteArray = receiver.get_packet() + + # Make sure it is local, this may be disregarded in some situations in the future? + # FIXME: If issues happen, remove this check. + var _packet_ip = receiver.get_packet_ip() + if not local_addresses.has(receiver.get_packet_ip()): + continue + + # Packet is big endian. Little endian is all that the extension methods of PackedByteArray support. + # We must use a StreamPeerBuffer. + # Source: https://github.com/godotengine/godot-proposals/issues/9586#issuecomment-2074227585 + var packet : StreamPeerBuffer = StreamPeerBuffer.new() + packet.data_array = packet_bytes + packet.big_endian = true + + var dns_packet : DNSPacket = DNSPacket.from_packet(packet) + + on_receive.emit(dns_packet, packet) + +## Sends DNS Packet to all connected client peers (UDP). +func send_packet(packet : DNSPacket): + var raw_packet : StreamPeerBuffer = packet.to_packet() + var byte_array : PackedByteArray = raw_packet.data_array + for client : PacketPeerUDP in clients: + client.put_packet(byte_array) diff --git a/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd.uid b/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd.uid new file mode 100644 index 0000000..c2480bc --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/MulticastDNS.gd.uid @@ -0,0 +1 @@ +uid://bfemeu7ysbxwc diff --git a/Mods/VRChatOSC/godot-multicast-dns/README.md b/Mods/VRChatOSC/godot-multicast-dns/README.md new file mode 100644 index 0000000..ca7156a --- /dev/null +++ b/Mods/VRChatOSC/godot-multicast-dns/README.md @@ -0,0 +1,5 @@ +# Description +This library implements DNS and Multicast DNS (MDNS) functionality, primarily receiving MDNS responses. + +# Features +Interprets query responses and allows easy access, allows for sending DNS Packets in response. diff --git a/Mods/VRChatOSC/godottpd/http_file_router.gd b/Mods/VRChatOSC/godottpd/http_file_router.gd new file mode 100644 index 0000000..f6698a7 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_file_router.gd @@ -0,0 +1,203 @@ +## Class inheriting HttpRouter for handling file serving requests +## +## NOTE: This class mainly handles behind the scenes stuff. +class_name HttpFileRouter +extends HttpRouter + +## Full path to the folder which will be exposed to web +var path: String = "" + +## Relative path to the index page, which will be served when a request is made to "/" (server root) +var index_page: String = "index.html" + +## Relative path to the fallback page which will be served if the requested file was not found +var fallback_page: String = "" + +## An ordered list of extensions that will be checked +## if no file extension is provided by the request +var extensions: PackedStringArray = ["html"] + +## A list of extensions that will be excluded if requested +var exclude_extensions: PackedStringArray = [] + +var weekdays: Array[String] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +var monthnames: Array[String] = ['___', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +## Creates an HttpFileRouter intance +## [br] +## [br][param path] - Full path to the folder which will be exposed to web. +## [br][param options] - Optional Dictionary of options which can be configured: +## [br] - [param fallback_page]: Full path to the fallback page which will be served if the requested file was not found +## [br] - [param extensions]: A list of extensions that will be checked if no file extension is provided by the request +## [br] - [param exclude_extensions]: A list of extensions that will be excluded if requested +func _init( + path: String, + options: Dictionary = { + 'index_page': index_page, + 'fallback_page': fallback_page, + 'extensions': extensions, + 'exclude_extensions': exclude_extensions, + } + ) -> void: + self.path = path + self.index_page = options.get("index_page", self.index_page) + self.fallback_page = options.get("fallback_page", self.fallback_page) + self.extensions = options.get("extensions", self.extensions) + self.exclude_extensions = options.get("exclude_extensions", self.exclude_extensions) + +## Handle a GET request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The response to send to the clinet +func handle_get(request: HttpRequest, response: HttpResponse) -> void: + var serving_path: String = path + request.path + var file_exists: bool = _file_exists(serving_path) + + if request.path == "/" and not file_exists: + if index_page.length() > 0: + serving_path = path + "/" + index_page + file_exists = _file_exists(serving_path) + + if request.path.get_extension() == "" and not file_exists: + for extension in extensions: + serving_path = path + request.path + "." + extension + file_exists = _file_exists(serving_path) + if file_exists: + break + + # GDScript must be excluded, unless it is used as a preprocessor (php-like) + if (file_exists and not serving_path.get_extension() in ["gd"] + Array(exclude_extensions)): + var modifiedtime = FileAccess.get_modified_time(serving_path) + var time = Time.get_datetime_dict_from_unix_time(modifiedtime) + var weekday = weekdays[time.weekday] + var monthname = monthnames[time.month] + var timestamp = '%s, %02d %s %04d %02d:%02d:%02d GMT' % [weekday, time.day, monthname, time.year, time.hour, time.minute, time.second] + + if request.headers.get('If-Modified-Since') == timestamp: + response.send_raw(304, ''.to_ascii_buffer(), _get_mime(serving_path.get_extension())) + else: + if request.headers.has('Range'): + var rdata: PackedStringArray = request.headers['Range'].split('=') + var brequest: PackedStringArray = rdata[1].split('-') + if brequest[0].is_valid_int(): + var start: int = brequest[0].to_int() + var file: FileAccess = FileAccess.open(serving_path, FileAccess.READ) + var size = file.get_length() + file.close() + response.send_raw( + 206, + _serve_file(serving_path, start), + _get_mime(serving_path.get_extension()), + "Cache-Control: no-cache\r\nLast-Modified: %s\r\nContent-Range: bytes %s-%s/%s\n\r" % [timestamp, start, size-1, size] + ) + else: + response.send_raw( + 200, + _serve_file(serving_path), + _get_mime(serving_path.get_extension()), + "Cache-Control: no-cache\r\nLast-Modified: %s\r\n" % timestamp + ) + else: + if fallback_page.length() > 0: + serving_path = path + "/" + fallback_page + response.send_raw(200 if index_page == fallback_page else 404, _serve_file(serving_path), _get_mime(fallback_page.get_extension())) + else: + response.send_raw(404) + +# Reads a file as text +# +# #### Parameters +# - file_path: Full path to the file +func _serve_file(file_path: String, seek: int = -1) -> PackedByteArray: + var content: PackedByteArray = [] + var file: FileAccess = FileAccess.open(file_path, FileAccess.READ) + var error = file.get_open_error() + if error: + content = ("Couldn't serve file, ERROR = %s" % error).to_ascii_buffer() + else: + if seek != -1 and seek < file.get_length(): + file.seek(seek) + content = file.get_buffer(file.get_length()) + file.close() + return content + +# Check if a file exists +# +# #### Parameters +# - file_path: Full path to the file +func _file_exists(file_path: String) -> bool: + return FileAccess.file_exists(file_path) + +# Get the full MIME type of a file from its extension +# +# #### Parameters +# - file_extension: Extension of the file to be served +func _get_mime(file_extension: String) -> String: + var type: String = "application" + var subtype : String = "octet-stream" + match file_extension: + # Web files + "css","html","csv","js","mjs": + type = "text" + subtype = "javascript" if file_extension in ["js","mjs"] else file_extension + "php": + subtype = "x-httpd-php" + "ttf","woff","woff2": + type = "font" + subtype = file_extension + # Image + "png","bmp","gif","png","webp": + type = "image" + subtype = file_extension + "jpeg","jpg": + type = "image" + subtype = "jpg" + "tiff", "tif": + type = "image" + subtype = "jpg" + "svg": + type = "image" + subtype = "svg+xml" + "ico": + type = "image" + subtype = "vnd.microsoft.icon" + # Documents + "doc": + subtype = "msword" + "docx": + subtype = "vnd.openxmlformats-officedocument.wordprocessingml.document" + "7z": + subtype = "x-7x-compressed" + "gz": + subtype = "gzip" + "tar": + subtype = "application/x-tar" + "json","pdf","zip": + subtype = file_extension + "txt": + type = "text" + subtype = "plain" + "ppt": + subtype = "vnd.ms-powerpoint" + # Audio + "midi","mp3","wav": + type = "audio" + subtype = file_extension + "mp4","mpeg","webm": + type = "audio" + subtype = file_extension + "oga","ogg": + type = "audio" + subtype = "ogg" + "mpkg": + subtype = "vnd.apple.installer+xml" + # Video + "ogv": + type = "video" + subtype = "ogg" + "avi": + type = "video" + subtype = "x-msvideo" + "ogx": + subtype = "ogg" + return type + "/" + subtype diff --git a/Mods/VRChatOSC/godottpd/http_file_router.gd.uid b/Mods/VRChatOSC/godottpd/http_file_router.gd.uid new file mode 100644 index 0000000..8b47a16 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_file_router.gd.uid @@ -0,0 +1 @@ +uid://d00hhtkp6c38n diff --git a/Mods/VRChatOSC/godottpd/http_request.gd b/Mods/VRChatOSC/godottpd/http_request.gd new file mode 100644 index 0000000..24d41a9 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_request.gd @@ -0,0 +1,53 @@ +## An HTTP request received by the server +class_name HttpRequest +extends RefCounted + + +## A dictionary of the headers of the request +var headers: Dictionary + +## The received raw body +var body: String + +## A match object of the regular expression that matches the path +var query_match: RegExMatch + +## The path that matches the router path +var path: String + +## The method +var method: String + +## A dictionary of request (aka. routing) parameters +var parameters: Dictionary + +## A dictionary of request query parameters +var query: Dictionary + +## Returns the body object based on the raw body and the content type of the request +func get_body_parsed() -> Variant: + var content_type: String = "" + + if(headers.has("content-type")): + content_type = headers["content-type"] + elif(headers.has("Content-Type")): + content_type = headers["Content-Type"] + + if(content_type == "application/json"): + return JSON.parse_string(body) + + if(content_type == "application/x-www-form-urlencoded"): + var data = {} + + for body_part in body.split("&"): + var key_and_value = body_part.split("=") + data[key_and_value[0]] = key_and_value[1] + + return data + + # Not supported contenty type parsing... for now + return null + +## Override `str()` method, automatically called in `print()` function +func _to_string() -> String: + return JSON.stringify({headers=headers, method=method, path=path}) diff --git a/Mods/VRChatOSC/godottpd/http_request.gd.uid b/Mods/VRChatOSC/godottpd/http_request.gd.uid new file mode 100644 index 0000000..ad460e8 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_request.gd.uid @@ -0,0 +1 @@ +uid://csngl33dupj6u diff --git a/Mods/VRChatOSC/godottpd/http_response.gd b/Mods/VRChatOSC/godottpd/http_response.gd new file mode 100644 index 0000000..2ec0c64 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_response.gd @@ -0,0 +1,183 @@ +## A response object useful to send out responses +class_name HttpResponse +extends RefCounted + + +## The client currently talking to the server +var client: StreamPeer + +## The server identifier to use on responses [GodotTPD] +var server_identifier: String = "GodotTPD" + +## A dictionary of headers +## [br] Headers can be set using the `set_header(name, value)` function +var headers: Dictionary = {} + +## An array of cookies +## [br] Cookies can be set using the `cookie(name, value, options)` function +## [br] Cookies will be automatically sent via "Set-Cookie" headers to clients +var cookies: Array = [] + +## Origins allowed to call this resource +var access_control_origin = "*" + +## Comma separed methods for the access control +var access_control_allowed_methods = "POST, GET, OPTIONS" + +## Comma separed headers for the access control +var access_control_allowed_headers = "content-type" + +## Send out a raw (Bytes) response to the client +## [br]Useful to send files faster or raw data which will be converted by the client +## [br][param status] - The HTTP Status code to send +## [br][param data] - The body data to send +## [br][param content_type] - The type of content to send. +func send_raw(status_code: int, data: PackedByteArray = PackedByteArray([]), content_type: String = "application/octet-stream", extra_header: String = "") -> void: + client.put_data(("HTTP/1.1 %d %s\r\n" % [status_code, _match_status_code(status_code)]).to_ascii_buffer()) + client.put_data(("Server: %s\r\n" % server_identifier).to_ascii_buffer()) + for header in headers.keys(): + client.put_data(("%s: %s\r\n" % [header, headers[header]]).to_ascii_buffer()) + for cookiez in cookies: + client.put_data(("Set-Cookie: %s\r\n" % cookiez).to_ascii_buffer()) + client.put_data(("Content-Length: %d\r\n" % data.size()).to_ascii_buffer()) + client.put_data("Connection: close\r\n".to_ascii_buffer()) + client.put_data(("Access-Control-Allow-Origin: %s\r\n" % access_control_origin).to_ascii_buffer()) + client.put_data(("Access-Control-Allow-Methods: %s\r\n" % access_control_allowed_methods).to_ascii_buffer()) + client.put_data(("Access-Control-Allow-Headers: %s\r\n" % access_control_allowed_headers).to_ascii_buffer()) + client.put_data("Accept-Ranges: bytes\r\n".to_ascii_buffer()) + client.put_data(extra_header.to_ascii_buffer()) + client.put_data(("Content-Type: %s\r\n\r\n" % content_type).to_ascii_buffer()) + + client.put_data(data) + +## For sending parts of data +## [br]TODO: http_file_router.gd - use this to send small parts of large files at a time to avoid smashing the ram of the server +## [br]TODO: This will probably be used for range header? +func send_partial(status_code: int, data: PackedByteArray = PackedByteArray([]), content_type: String = "application/octet-stream", extra_header: String = "") -> void: + client.put_data(data) + +## Send out a response to the client +## [br] +## [br][param status_code] - The HTTP status code to send +## [br][param data] - The body to send +## [br][param content_type] - The type of the content to send +func send(status_code: int, data: String = "", content_type = "text/html") -> void: + send_raw(status_code, data.to_ascii_buffer(), content_type) + +## Send out a JSON response to the client +## [br] This function will internally call the [method send] +## [br] +## [br][param status_code] - The HTTP status code to send +## [br][param data] - The body to send +func json(status_code: int, data) -> void: + send(status_code, JSON.stringify(data), "application/json") + + +## Sets the response’s header "field" to "value" +## [br] +## [br][param field] - The name of the header. i.e. [code]Accept-Type[/code] +## [br][param value] - The value of this header. i.e. [code]application/json[/code] +func set_header(field: StringName, value: Variant) -> void: + headers[field] = value + + +## Sets cookie "name" to "value" +## [br] +## [br][param name] - The name of the cookie. i.e. [code]user-id[/code] +## [br][param value] - The value of this cookie. i.e. [code]abcdef[/code] +## [br][param options] - A Dictionary of [url=https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes]cookie attributes[/url] +## for this specific cokkie in the [code]{ "secure" : "true"}[/code] format. +func cookie(name: String, value: String, options: Dictionary = {}) -> void: + var cookie_tmp: String = name+"="+value + if options.has("domain"): cookie_tmp+="; Domain="+options["domain"] + if options.has("max-age"): cookie_tmp+="; Max-Age="+options["max-age"] + if options.has("expires"): cookie_tmp+="; Expires="+options["expires"] + if options.has("path"): cookie_tmp+="; Path="+options["path"] + if options.has("secure"): cookie_tmp+="; Secure="+options["secure"] + if options.has("httpOnly"): cookie_tmp+="; HttpOnly="+options["httpOnly"] + if options.has("sameSite"): + match (options["sameSite"]): + true: cookie_tmp += "; SameSite=Strict" + "lax": cookie_tmp += "; SameSite=Lax" + "strict": cookie_tmp += "; SameSite=Strict" + "none": cookie_tmp += "; SameSite=None" + _: pass + cookies.append(cookie_tmp) + + +## Automatically matches a "status_code" to an RFC 7231 compliant "status_text" +## [br] +## [br][param code] - The HTTP Status code to be matched +## [br]Returns: the matched [code]status_text[/code] +func _match_status_code(code: int) -> String: + var text: String = "OK" + match(code): + # 1xx - Informational Responses + 100: text="Continue" + 101: text="Switching protocols" + 102: text="Processing" + 103: text="Early Hints" + # 2xx - Successful Responses + 200: text="OK" + 201: text="Created" + 202: text="Accepted" + 203: text="Non-Authoritative Information" + 204: text="No Content" + 205: text="Reset Content" + 206: text="Partial Content" + 207: text="Multi-Status" + 208: text="Already Reported" + 226: text="IM Used" + # 3xx - Redirection Messages + 300: text="Multiple Choices" + 301: text="Moved Permanently" + 302: text="Found (Previously 'Moved Temporarily')" + 303: text="See Other" + 304: text="Not Modified" + 305: text="Use Proxy" + 306: text="Switch Proxy" + 307: text="Temporary Redirect" + 308: text="Permanent Redirect" + # 4xx - Client Error Responses + 400: text="Bad Request" + 401: text="Unauthorized" + 402: text="Payment Required" + 403: text="Forbidden" + 404: text="Not Found" + 405: text="Method Not Allowed" + 406: text="Not Acceptable" + 407: text="Proxy Authentication Required" + 408: text="Request Timeout" + 409: text="Conflict" + 410: text="Gone" + 411: text="Length Required" + 412: text="Precondition Failed" + 413: text="Payload Too Large" + 414: text="URI Too Long" + 415: text="Unsupported Media Type" + 416: text="Range Not Satisfiable" + 417: text="Expectation Failed" + 418: text="I'm a Teapot" + 421: text="Misdirected Request" + 422: text="Unprocessable Entity" + 423: text="Locked" + 424: text="Failed Dependency" + 425: text="Too Early" + 426: text="Upgrade Required" + 428: text="Precondition Required" + 429: text="Too Many Requests" + 431: text="Request Header Fields Too Large" + 451: text="Unavailable For Legal Reasons" + # 5xx - Server Error Responses + 500: text="Internal Server Error" + 501: text="Not Implemented" + 502: text="Bad Gateway" + 503: text="Service Unavailable" + 504: text="Gateway Timeout" + 505: text="HTTP Version Not Supported" + 506: text="Variant Also Negotiates" + 507: text="Insufficient Storage" + 508: text="Loop Detected" + 510: text="Not Extended" + 511: text="Network Authentication Required" + return text diff --git a/Mods/VRChatOSC/godottpd/http_response.gd.uid b/Mods/VRChatOSC/godottpd/http_response.gd.uid new file mode 100644 index 0000000..41d0e0f --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_response.gd.uid @@ -0,0 +1 @@ +uid://s3kas615o7k5 diff --git a/Mods/VRChatOSC/godottpd/http_router.gd b/Mods/VRChatOSC/godottpd/http_router.gd new file mode 100644 index 0000000..6c67839 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_router.gd @@ -0,0 +1,77 @@ +## A base class for all HTTP routers +## +## This router handles all the requests that the client sends to the server. +## [br]NOTE: This class is meant to be expanded upon instead of used directly. +## [br]Usage: +## [codeblock] +## class_name MyCustomRouter +## extends HttpRouter +## +## func handle_get(request: HttpRequest, response: HttpResponse) -> void: +## response.send(200, "Hello World") +## [/codeblock] +class_name HttpRouter +extends RefCounted + + +## Handle a GET request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_get(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "GET not allowed") + + +## Handle a POST request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_post(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "POST not allowed") + + +## Handle a HEAD request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_head(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "HEAD not allowed") + + +## Handle a PUT request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_put(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "PUT not allowed") + + +## Handle a PATCH request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_patch(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "PATCH not allowed") + + +## Handle a DELETE request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_delete(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "DELETE not allowed") + + +## Handle an OPTIONS request +## [br] +## [br][param request] - The request from the client +## [br][param response] - The node to send the response back to the client +@warning_ignore("unused_parameter") +func handle_options(request: HttpRequest, response: HttpResponse) -> void: + response.send(405, "OPTIONS not allowed") diff --git a/Mods/VRChatOSC/godottpd/http_router.gd.uid b/Mods/VRChatOSC/godottpd/http_router.gd.uid new file mode 100644 index 0000000..3ed38f9 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_router.gd.uid @@ -0,0 +1 @@ +uid://b6210q0cp5u8a diff --git a/Mods/VRChatOSC/godottpd/http_server.gd b/Mods/VRChatOSC/godottpd/http_server.gd new file mode 100644 index 0000000..b3a85a6 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_server.gd @@ -0,0 +1,318 @@ +## A routable HTTP server for Godot +## +## Provides a web server with routes for specific endpoints +## [br]Example usage: +## [codeblock] +## var server := HttpServer.new() +## server.register_router("/", MyExampleRouter.new()) +## add_child(server) +## server.start() +## [/codeblock] + +class_name HttpServer +extends Node + +## The ip address to bind the server to. Use * for all IP addresses [*] +var bind_address: String = "*" + +## The port to bind the server to. [8080] +var port: int = 8080 + +## The server identifier to use when responding to requests [GodotTPD] +var server_identifier: String = "GodotTPD" + +# If `HttpRequest`s and `HttpResponse`s should be logged +var _logging: bool = false + +# The TCP server instance used +var _server: TCPServer + +# An array of StraemPeerTCP objects who are currently talking to the server +var _clients: Array + +# A list of HttpRequest routers who could handle a request +var _routers: Array = [] + +# A regex identifiying the method line +var _method_regex: RegEx = RegEx.new() + +# A regex for header lines +var _header_regex: RegEx = RegEx.new() + +# The base path used in a project to serve files +var _local_base_path: String = "res://src" + +# list of host allowed to call the server +var _allowed_origins: PackedStringArray = [] + +# Comma separed methods for the access control +var _access_control_allowed_methods = "POST, GET, OPTIONS" + +# Comma separed headers for the access control +var _access_control_allowed_headers = "content-type" + +# Compile the required regex +func _init(_logging: bool = false): + self._logging = _logging + set_process(false) + _method_regex.compile("^(?GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS) (?[^ ]+) HTTP/1.1$") + _header_regex.compile("^(?[\\w-]+): (?(.*))$") + +# Print a debug message in console, if the debug mode is enabled +# +# #### Parameters +# - message: The message to be printed (only in debug mode) +func _print_debug(message: String) -> void: + var time = Time.get_datetime_dict_from_system() + var time_return = "%02d-%02d-%02d %02d:%02d:%02d" % [time.year, time.month, time.day, time.hour, time.minute, time.second] + print("[SERVER] ",time_return," >> ", message) + +## Register a new router to handle a specific path +## [br] +## [br][param path] - The path the router will handle. +## Supports a regular expression and the group matches will be available in HttpRequest.query_match. +## [br][param router] - The router which will handle the request +func register_router(path: String, router: HttpRouter, condition: Callable = func(request: HttpRequest): return true): + var path_regex = RegEx.new() + var params: Array = [] + if path.left(0) == "^": + path_regex.compile(path) + else: + var regexp: Array = _path_to_regexp(path, router is HttpFileRouter) + path_regex.compile(regexp[0]) + params = regexp[1] + _routers.push_back({ + "path": path_regex, + "params": params, + "router": router, + "condition": condition, + }) + + +## Handle possibly incoming requests +func _process(_delta: float) -> void: + if _server: + while _server.is_connection_available(): + var new_client = _server.take_connection() + if new_client: + self._clients.append(new_client) + for client in self._clients: + client.poll() + if client.get_status() == StreamPeerTCP.STATUS_CONNECTED: + var bytes = client.get_available_bytes() + if bytes > 0: + var request_string = client.get_utf8_string(bytes) + self._handle_request(client, request_string) + _remove_disconnected_clients() + + +func _remove_disconnected_clients(): + var valid_statuses = [StreamPeerTCP.STATUS_CONNECTED, StreamPeerTCP.STATUS_CONNECTING] + self._clients = self._clients.filter( + func(c: StreamPeerTCP): return valid_statuses.has(c.get_status()) + ) + + +## Start the server +func start(): + set_process(true) + self._server = TCPServer.new() + var err: int = self._server.listen(self.port, self.bind_address) + match err: + 22: + _print_debug("Could not bind to port %d, already in use" % [self.port]) + stop() + _: + _print_debug("HTTP Server listening on http://%s:%s" % [self.bind_address, self.port]) + + +## Stop the server and disconnect all clients +func stop(): + for client in self._clients: + client.disconnect_from_host() + self._clients.clear() + self._server.stop() + set_process(false) + _print_debug("Server stopped.") + + +# Interpret a request string and perform the request +# +# #### Parameters +# - client: The client that send the request +# - request: The received request as a String +func _handle_request(client: StreamPeer, request_string: String): + var request = HttpRequest.new() + for line in request_string.split("\r\n"): + var method_matches = _method_regex.search(line) + var header_matches = _header_regex.search(line) + if method_matches: + request.method = method_matches.get_string("method") + var request_path: String = method_matches.get_string("path") + # Check if request_path contains "?" character, could be a query parameter + if not "?" in request_path: + request.path = request_path + else: + var path_query: PackedStringArray = request_path.split("?") + request.path = path_query[0] + request.query = _extract_query_params(path_query[1]) + request.headers = {} + request.body = "" + elif header_matches: + request.headers[header_matches.get_string("key")] = \ + header_matches.get_string("value") + else: + request.body += line + self._perform_current_request(client, request) + + +# Handle a specific request and send it to a router +# If no router matches, send a 404 +# +# #### Parameters +# - client: The client that send the request +# - request_info: A dictionary with information about the request +# - method: The method of the request (e.g. GET, POST) +# - path: The requested path +# - headers: A dictionary of headers of the request +# - body: The raw body of the request +func _perform_current_request(client: StreamPeer, request: HttpRequest): + var thread = Thread.new() + thread.start(__perform_current_request.bind(client, request)) + +func __perform_current_request(client: StreamPeer, request: HttpRequest): + _print_debug("HTTP Request: " + str(request)) + var found = false + var is_allowed_origin = false + var response = HttpResponse.new() + var fetch_mode = "" + var origin = "" + response.client = client + response.server_identifier = server_identifier + + if request.headers.has("Sec-Fetch-Mode"): + fetch_mode = request.headers["Sec-Fetch-Mode"] + elif request.headers.has("sec-fetch-mode"): + fetch_mode = request.headers["sec-fetch-mode"] + + if request.headers.has("Origin"): + origin = request.headers["Origin"] + elif request.headers.has("origin"): + origin = request.headers["origin"] + + if _allowed_origins.has(origin): + is_allowed_origin = true + response.access_control_origin = origin + + response.access_control_allowed_methods = _access_control_allowed_methods + response.access_control_allowed_headers = _access_control_allowed_headers + + for router in self._routers: + if not router.condition.bind(request).call(): break + + var matches = router.path.search(request.path) + if matches: + request.query_match = matches + if request.query_match.get_string("subpath"): + request.path = request.query_match.get_string("subpath") + if router.params.size() > 0: + for parameter in router.params: + request.parameters[parameter] = request.query_match.get_string(parameter) + match request.method: + "GET": + found = true + router.router.handle_get(request, response) + "POST": + found = true + router.router.handle_post(request, response) + "HEAD": + found = true + router.router.handle_head(request, response) + "PUT": + found = true + router.router.handle_put(request, response) + "PATCH": + found = true + router.router.handle_patch(request, response) + "DELETE": + found = true + router.router.handle_delete(request, response) + "OPTIONS": + if _allowed_origins.size() > 0 && fetch_mode == "cors": + if is_allowed_origin: + response.send(204) + else: + response.send(400, "%s is not present in the allowed origins" % origin) + + return + + found = true + router.router.handle_options(request, response) + break + if not found: + response.send(404, "Not found") + + +# Converts a URL path to @regexp RegExp, providing a mechanism to fetch groups from the expression +# indexing each parameter by name in the @params array +# +# #### Parameters +# - path: The path of the HttpRequest +# - should_match_subfolder: (dafult [false]) if subfolders should be matched and grouped, +# used for HttpFileRouter +# +# Returns: A 2D array, containing a @regexp String and Dictionary of @params +# [0] = @regexp --> the output expression as a String, to be compiled in RegExp +# [1] = @params --> an Array of parameters, indexed by names +# ex. "/user/:id" --> "^/user/(?([^/#?]+?))[/#?]?$" +func _path_to_regexp(path: String, should_match_subfolders: bool = false) -> Array: + var regexp: String = "^" + var params: Array = [] + var fragments: Array = path.split("/") + fragments.pop_front() + for fragment in fragments: + if fragment.left(1) == ":": + fragment = fragment.lstrip(":") + regexp += "/(?<%s>([^/#?]+?))" % fragment + params.append(fragment) + else: + regexp += "/" + fragment + regexp += "[/#?]?$" if not should_match_subfolders else "(?$|/.*)" + return [regexp, params] + + +## Enable CORS (Cross-origin resource sharing) which only allows requests from the specified servers +## [br] +## [br][param allowed_origins] - The origins that are allowed to be accessed from this server +## [br][param access_control_allowed_methods] - The methods that are allowed to be used +## [br][param access_control_allowed_headers] - The headers that are allowed to be sent +func enable_cors(allowed_origins: PackedStringArray, access_control_allowed_methods : String = "POST, GET, OPTIONS", access_control_allowed_headers : String = "content-type"): + _allowed_origins = allowed_origins + _access_control_allowed_methods = access_control_allowed_methods + _access_control_allowed_headers = access_control_allowed_headers + + +# Extracts query parameters from a String query, +# building a Query Dictionary of param:value pairs +# +# #### Parameters +# - query_string: the query string, extracted from the HttpRequest.path +# +# Returns: A Dictionary of param:value pairs +func _extract_query_params(query_string: String) -> Dictionary: + var query: Dictionary = {} + if query_string == "": + return query + var parameters: Array = query_string.split("&") + for param in parameters: + if not "=" in param: + continue + var kv : Array = param.split("=") + var value: String = kv[1] + if value.is_valid_int(): + query[kv[0]] = value.to_int() + elif value.is_valid_float(): + query[kv[0]] = value.to_float() + else: + query[kv[0]] = value + return query diff --git a/Mods/VRChatOSC/godottpd/http_server.gd.uid b/Mods/VRChatOSC/godottpd/http_server.gd.uid new file mode 100644 index 0000000..20902e6 --- /dev/null +++ b/Mods/VRChatOSC/godottpd/http_server.gd.uid @@ -0,0 +1 @@ +uid://b616mq2u5dtyn diff --git a/Mods/VRChatOSC/osc-query/OSCQueryServer.gd b/Mods/VRChatOSC/osc-query/OSCQueryServer.gd new file mode 100644 index 0000000..001acc0 --- /dev/null +++ b/Mods/VRChatOSC/osc-query/OSCQueryServer.gd @@ -0,0 +1,102 @@ +extends Node +class_name OSCQueryServer + +@export var osc_server : KiriOSCServer +@export var app_name : String +@export var osc_paths : Dictionary = {} +@export var osc_server_ip : String = "127.0.0.1" +@export var osc_server_port : int = 9001 +@export var osc_query_server_port : int = 61613 +@export var http_server : HttpServer + +var running : bool = false +signal on_host_info_requested +signal on_root_requested +signal on_osc_server_message_received(address : String, args) + +func _ready(): + start() + +func start() -> void: + running = true + osc_server.change_port_and_ip(osc_server_port, osc_server_ip) + if len(osc_server.message_received.get_connections()) == 0: + osc_server.message_received.connect(_message_received) + osc_server.start_server() + + var host_info_router = OSCQueryHostInfoRouter.new() + host_info_router.query_server = self + var address_router = OSCQueryAddressRouter.new() + address_router.query_server = self + + # Add if not already added. + if http_server == null: + http_server = HttpServer.new() + http_server.bind_address = "127.0.0.1" + http_server.port = osc_query_server_port + add_child(http_server) + http_server.register_router("^/HOST_INFO", host_info_router) + http_server.register_router("^/", address_router) + + http_server.start() + +func stop() -> void: + http_server.stop() + osc_server.stop_server() + running = false + +func set_osc_server_port(new_port : int) -> void: + if new_port != osc_server_port: + osc_server_port = new_port + stop() + start() + else: + osc_server_port = new_port + +func set_osc_query_server_port(new_port : int) -> void: + if new_port != osc_query_server_port: + osc_query_server_port = new_port + stop() + start() + else: + osc_query_server_port = new_port + +func _message_received(address : String, args) -> void: + on_osc_server_message_received.emit(address, args) + +class OSCQueryHostInfoRouter: + extends HttpRouter + var query_server : OSCQueryServer + + func handle_get(_request: HttpRequest, response: HttpResponse): + query_server.on_host_info_requested.emit() + var data = { + "NAME": query_server.app_name, + "OSC_IP": query_server.osc_server_ip, + "OSC_PORT": query_server.osc_server_port, + "OSC_TRANSPORT": "UDP", + "EXTENSIONS": { + "ACCESS": true, + "CLIPMODE": false, + "RANGE": true, + "TYPE": true, + "VALUE": true + } + } + var host_info_json = JSON.stringify(data) + response.send(200, host_info_json, "application/json") + +class OSCQueryAddressRouter: + extends HttpRouter + var query_server : OSCQueryServer + + func handle_get(_request: HttpRequest, response: HttpResponse): + query_server.on_root_requested.emit() + var data = { + "DESCRIPTION": "Root", + "FULL_PATH": "/", + "ACCESS": 0, + "CONTENTS": query_server.osc_paths, + } + var root_json = JSON.stringify(data) + response.send(200, root_json, "application/json") diff --git a/Mods/VRChatOSC/osc-query/OSCQueryServer.gd.uid b/Mods/VRChatOSC/osc-query/OSCQueryServer.gd.uid new file mode 100644 index 0000000..c8929ea --- /dev/null +++ b/Mods/VRChatOSC/osc-query/OSCQueryServer.gd.uid @@ -0,0 +1 @@ +uid://bg1sitssmsggs