├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yaml └── workflows │ └── build.yaml ├── .gitignore ├── .gitmodules ├── OpenVR2Key.sln ├── OpenVR2Key ├── App.config ├── App.xaml ├── App.xaml.cs ├── MainController.cs ├── MainModel.cs ├── MainUtils.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── OpenVR2Key.csproj ├── Properties │ ├── Resources.Designer.cs │ ├── Resources.resx │ ├── Settings.Designer.cs │ └── Settings.settings ├── actions.json ├── app.vrmanifest ├── bindings_knuckles.json ├── bindings_oculus_touch.json ├── bindings_vive_controller.json ├── bindings_vive_tracker_camera.json ├── bindings_vive_tracker_chest.json ├── bindings_vive_tracker_handed.json ├── bindings_vive_tracker_head.json ├── bindings_vive_tracker_left_elbow.json ├── bindings_vive_tracker_left_foot.json ├── bindings_vive_tracker_left_knee.json ├── bindings_vive_tracker_left_shoulder.json ├── bindings_vive_tracker_right_elbow.json ├── bindings_vive_tracker_right_foot.json ├── bindings_vive_tracker_right_knee.json ├── bindings_vive_tracker_right_shoulder.json ├── bindings_vive_tracker_waist.json └── resources │ ├── logo.ico │ └── logo.png └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | description: Create a report to help resolve issues with the software 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Current behavior 8 | description: A clear and concise description of what the bug is. 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Steps to reproduce the behavior 14 | description: Steps to reproduce the behavior. 15 | placeholder: | 16 | 1. Run game '...' 17 | 2. Run software '....' 18 | 3. Set input to '....' 19 | 4. Set keys to '...' 20 | 5. See error 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Expected behavior 26 | description: A clear and concise description of what you expected to happen. 27 | validations: 28 | required: true 29 | - type: input 30 | id: os 31 | attributes: 32 | label: Operating system version 33 | description: "Which operating system version do you run?" 34 | placeholder: "Example: Windows 10 Pro" 35 | validations: 36 | required: true 37 | - type: input 38 | id: steamvr 39 | attributes: 40 | label: SteamVR version 41 | description: "Which version of SteamVR are you using?" 42 | placeholder: "Example: 1.26" 43 | validations: 44 | required: true 45 | - type: input 46 | id: app 47 | attributes: 48 | label: Application version 49 | description: "Which version of OpenVR2Key are you using?" 50 | placeholder: "Example: Windows 10 Pro" 51 | validations: 52 | required: true 53 | - type: input 54 | id: apploc 55 | attributes: 56 | label: Application location 57 | description: "Where on your disk are you running OpenVR2Key from?" 58 | placeholder: "Example: C:\\Temp\\OpenVR2Key\\" 59 | validations: 60 | required: true 61 | - type: input 62 | id: hmd 63 | attributes: 64 | label: VR HMD 65 | description: "What VR headset are you using?" 66 | placeholder: "Example: Valve Index" 67 | validations: 68 | required: true 69 | - type: input 70 | id: input 71 | attributes: 72 | label: VR hardware 73 | description: "What VR controllers are you using?" 74 | placeholder: "Example: Valve Index Controllers" 75 | validations: 76 | required: true 77 | - type: input 78 | id: software 79 | attributes: 80 | label: Software to control 81 | description: "What software are you trying to control with OpenVR2Key?" 82 | placeholder: "Example: Discord" 83 | validations: 84 | required: true 85 | - type: input 86 | id: game 87 | attributes: 88 | label: Game 89 | description: "What game are you running?" 90 | placeholder: "Example: Half-Life: Alyx" 91 | validations: 92 | required: false 93 | - type: textarea 94 | attributes: 95 | label: Additional information 96 | description: "Any additional information?" 97 | placeholder: "Tip: you can drag screenshots into this field to attach them." 98 | validations: 99 | required: false 100 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | submodules: 'recursive' 17 | 18 | - name: Setup dotnet 19 | uses: actions/setup-dotnet@v4 20 | with: 21 | dotnet-version: 8.x 22 | dotnet-quality: ga 23 | 24 | - name: Install OpenVR Dependencies 25 | shell: cmd 26 | run: | 27 | call cd EasyOpenVR 28 | call download_openvr_api_dependencies.cmd 29 | 30 | - name: Install dependencies 31 | run: dotnet restore 32 | 33 | - name: Build 34 | run: dotnet build OpenVR2Key/OpenVR2Key.csproj --configuration Release --runtime win-x64 35 | 36 | - name: Publish 37 | run: dotnet publish OpenVR2Key/OpenVR2Key.csproj --no-build --configuration Release -o release 38 | 39 | - name: Zip release build 40 | shell: bash 41 | run: | 42 | 7z a -tzip "OpenVR2Key_${{github.event.release.tag_name}}.zip" "./release/*" 43 | 44 | - name: Upload release artifact 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: release 48 | path: OpenVR2Key_${{github.event.release.tag_name}}.zip 49 | 50 | - name: Restore local tools 51 | run: dotnet tool restore 52 | 53 | release: 54 | name: Upload Release 55 | runs-on: ubuntu-latest 56 | needs: build 57 | 58 | steps: 59 | - name: Download artifact 60 | uses: actions/download-artifact@v4 61 | with: 62 | name: release 63 | 64 | - name: Display structure of downloaded files 65 | run: ls -R . 66 | 67 | - name: Upload file to release 68 | uses: softprops/action-gh-release@v2 69 | with: 70 | files: | 71 | **/*.zip 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "EasyOpenVR"] 2 | path = EasyOpenVR 3 | url = https://github.com/BOLL7708/EasyOpenVR.git 4 | [submodule "EasyFramework"] 5 | path = EasyFramework 6 | url = https://github.com/BOLL7708/EasyFramework.git 7 | -------------------------------------------------------------------------------- /OpenVR2Key.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28307.329 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenVR2Key", "OpenVR2Key\OpenVR2Key.csproj", "{752648BE-C543-40AA-98B2-722C886F5944}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EasyOpenVR", "EasyOpenVR\EasyOpenVR.csproj", "{E8F2F033-F02F-43F0-BC76-39C8497D739F}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EasyFramework", "EasyFramework\EasyFramework.csproj", "{3D8BE5FB-558D-4481-8C79-78272F8B2BDC}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Debug|x64 = Debug|x64 16 | Release|Any CPU = Release|Any CPU 17 | Release|x64 = Release|x64 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {752648BE-C543-40AA-98B2-722C886F5944}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {752648BE-C543-40AA-98B2-722C886F5944}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {752648BE-C543-40AA-98B2-722C886F5944}.Debug|x64.ActiveCfg = Debug|x64 23 | {752648BE-C543-40AA-98B2-722C886F5944}.Debug|x64.Build.0 = Debug|x64 24 | {752648BE-C543-40AA-98B2-722C886F5944}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {752648BE-C543-40AA-98B2-722C886F5944}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {752648BE-C543-40AA-98B2-722C886F5944}.Release|x64.ActiveCfg = Release|x64 27 | {752648BE-C543-40AA-98B2-722C886F5944}.Release|x64.Build.0 = Release|x64 28 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Debug|x64.ActiveCfg = Debug|Any CPU 31 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Debug|x64.Build.0 = Debug|Any CPU 32 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Release|x64.ActiveCfg = Release|Any CPU 35 | {E8F2F033-F02F-43F0-BC76-39C8497D739F}.Release|x64.Build.0 = Release|Any CPU 36 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Debug|x64.ActiveCfg = Debug|Any CPU 39 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Debug|x64.Build.0 = Debug|Any CPU 40 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Release|x64.ActiveCfg = Release|Any CPU 43 | {3D8BE5FB-558D-4481-8C79-78272F8B2BDC}.Release|x64.Build.0 = Release|Any CPU 44 | EndGlobalSection 45 | GlobalSection(SolutionProperties) = preSolution 46 | HideSolutionNode = FALSE 47 | EndGlobalSection 48 | GlobalSection(ExtensibilityGlobals) = postSolution 49 | SolutionGuid = {3207F212-5A47-435E-9726-9CCE4B4C6CEA} 50 | EndGlobalSection 51 | EndGlobal 52 | -------------------------------------------------------------------------------- /OpenVR2Key/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | False 15 | 16 | 17 | False 18 | 19 | 20 | False 21 | 22 | 23 | False 24 | 25 | 26 | True 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /OpenVR2Key/App.xaml: -------------------------------------------------------------------------------- 1 |  6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /OpenVR2Key/App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Configuration; 4 | using System.Data; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using System.Windows; 8 | 9 | namespace OpenVR2Key 10 | { 11 | /// 12 | /// Interaction logic for App.xaml 13 | /// 14 | public partial class App : Application 15 | { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /OpenVR2Key/MainController.cs: -------------------------------------------------------------------------------- 1 | using EasyOpenVR; 2 | using EasyFramework; 3 | using GregsStack.InputSimulatorStandard; 4 | using GregsStack.InputSimulatorStandard.Native; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Diagnostics; 8 | using System.IO; 9 | using System.Threading; 10 | using System.Windows; 11 | using System.Windows.Input; 12 | using EasyOpenVR.Utils; 13 | using Valve.VR; 14 | 15 | namespace OpenVR2Key 16 | { 17 | class MainController 18 | { 19 | private EasyOpenVRSingleton _ovr = EasyOpenVRSingleton.Instance; 20 | private InputSimulator _sim = new InputSimulator(); 21 | private bool _shouldShutDown = false; 22 | 23 | // Active key registration 24 | private string _registeringKey = string.Empty; 25 | private object _registeringElement = null; 26 | private HashSet _keys = new HashSet(); 27 | private HashSet _keysDown = new HashSet(); 28 | 29 | // Actions 30 | public Action StatusUpdateAction { get; set; } = (status) => { Debug.WriteLine("No status action set."); }; 31 | public Action AppUpdateAction { get; set; } = (appId) => { Debug.WriteLine("No appID action set."); }; 32 | public Action KeyTextUpdateAction { get; set; } = (status, cancel) => { Debug.WriteLine("No key text action set."); }; 33 | public Action, bool> ConfigRetrievedAction { get; set; } = (config, forceButtonOff) => { Debug.WriteLine("No config loaded."); }; 34 | public Action KeyActivatedAction { get; set; } = (key, on) => { Debug.WriteLine("No key simulated action set."); }; 35 | public Action DashboardVisibleAction { get; set; } = (visible) => { Debug.WriteLine("No dashboard visible action set."); }; 36 | 37 | // Other 38 | private string _currentApplicationId = ""; 39 | private ulong _inputSourceHandleLeft = 0, _inputSourceHandleRight = 0; 40 | private ulong[] _inputSourceHandles = new ulong[14]; 41 | private ulong _notificationOverlayHandle = 0; 42 | private string[] _actionKeys = new string[0]; 43 | 44 | public MainController() 45 | { 46 | } 47 | 48 | public void Init(string[] actionKeys) 49 | { 50 | _actionKeys = actionKeys; 51 | 52 | // Sets default values for status labels 53 | StatusUpdateAction.Invoke(false); 54 | AppUpdateAction.Invoke(MainModel.CONFIG_DEFAULT); 55 | KeyActivatedAction.Invoke(string.Empty, false); 56 | 57 | // Loads default config 58 | LoadConfig(true); 59 | 60 | // Start background thread 61 | var workerThread = new Thread(WorkerThread); 62 | workerThread.Start(); 63 | } 64 | public void SetDebugLogAction(Action action) 65 | { 66 | _ovr.SetDebugLogAction(action); 67 | } 68 | 69 | #region bindings 70 | public bool ToggleRegisteringKey(string actionKey, object sender, out object activeElement) 71 | { 72 | var active = _registeringKey == string.Empty; 73 | if (active) 74 | { 75 | _registeringKey = actionKey; 76 | _registeringElement = sender; 77 | _keysDown.Clear(); 78 | _keys.Clear(); 79 | activeElement = sender; 80 | } 81 | else 82 | { 83 | activeElement = _registeringElement; 84 | MainModel.RegisterBinding(_registeringKey, _keys); // TODO: Should only save existing configs 85 | _registeringKey = string.Empty; 86 | _registeringElement = null; 87 | } 88 | return active; 89 | } 90 | 91 | private void StopRegisteringKeys() 92 | { 93 | UpdateCurrentObject(true); 94 | _keysDown.Clear(); 95 | _keys.Clear(); 96 | _registeringKey = string.Empty; 97 | _registeringElement = null; 98 | } 99 | 100 | // Add incoming keys to the current binding 101 | public bool OnKeyDown(Key key) 102 | { 103 | if (_registeringElement == null) return true; 104 | if (MainUtils.MatchVirtualKey(key) != null) 105 | { 106 | if (_keysDown.Count == 0) _keys.Clear(); 107 | _keys.Add(key); 108 | _keysDown.Add(key); 109 | UpdateCurrentObject(); 110 | return true; 111 | } 112 | else 113 | { 114 | return false; 115 | } 116 | } 117 | public void OnKeyUp(Key key) 118 | { 119 | if (_registeringElement == null) return; 120 | if (key == Key.RightAlt) _keysDown.Remove(Key.LeftCtrl); // Because AltGr records as RightAlt+LeftCtrl 121 | _keysDown.Remove(key); 122 | UpdateCurrentObject(); 123 | } 124 | 125 | // Send text to UI to update label 126 | private void UpdateCurrentObject(bool cancel=false) 127 | { 128 | KeyTextUpdateAction.Invoke(GetKeysLabel(), cancel); 129 | } 130 | 131 | // Generate label text from keys 132 | public string GetKeysLabel(Key[] keys = null) 133 | { 134 | if (keys == null) 135 | { 136 | keys = new Key[_keys.Count]; 137 | _keys.CopyTo(keys); 138 | } 139 | List result = new List(); 140 | foreach (Key k in keys) 141 | { 142 | result.Add(k.ToString()); 143 | } 144 | return string.Join(" + ", result.ToArray()); 145 | } 146 | 147 | #endregion 148 | 149 | #region worker 150 | private void WorkerThread() 151 | { 152 | Thread.CurrentThread.IsBackground = true; 153 | bool initComplete = false; 154 | while (true) 155 | { 156 | Thread.Sleep(10); 157 | if (_ovr.IsInitialized()) 158 | { 159 | if (!initComplete) 160 | { 161 | initComplete = true; 162 | 163 | _ovr.AddApplicationManifest("./app.vrmanifest", "boll7708.openvr2key", true); 164 | _ovr.LoadActionManifest("./actions.json"); 165 | RegisterActions(); 166 | UpdateAppId(); 167 | StatusUpdateAction.Invoke(true); 168 | UpdateInputSourceHandles(); 169 | _notificationOverlayHandle = _ovr.InitNotificationOverlay("OpenVR2Key"); 170 | 171 | _ovr.RegisterEvent(EVREventType.VREvent_Quit, (data) => 172 | { 173 | _shouldShutDown = true; 174 | }); 175 | _ovr.RegisterEvent(EVREventType.VREvent_SceneApplicationChanged, (data) => 176 | { 177 | UpdateAppId(); 178 | }); 179 | _ovr.RegisterEvents(new EVREventType[] { 180 | EVREventType.VREvent_TrackedDeviceActivated, 181 | EVREventType.VREvent_TrackedDeviceRoleChanged, 182 | EVREventType.VREvent_TrackedDeviceUpdated }, 183 | (data) => 184 | { 185 | UpdateInputSourceHandles(); 186 | } 187 | ); 188 | _ovr.RegisterEvent(EVREventType.VREvent_DashboardActivated, (data) => 189 | { 190 | DashboardVisibleAction.Invoke(true); 191 | }); 192 | _ovr.RegisterEvent(EVREventType.VREvent_DashboardDeactivated, (data) => 193 | { 194 | DashboardVisibleAction.Invoke(false); 195 | }); 196 | DashboardVisibleAction.Invoke(OpenVR.Overlay.IsDashboardVisible()); // To convey the initial state if the Dashboard is visible on launch of application. 197 | } 198 | else 199 | { 200 | _ovr.UpdateActionStates(_inputSourceHandles, 0); 201 | 202 | _ovr.UpdateEvents(); 203 | 204 | if (_shouldShutDown) 205 | { 206 | _shouldShutDown = false; 207 | initComplete = false; 208 | _ovr.AcknowledgeShutdown(); 209 | _ovr.Shutdown(); 210 | StatusUpdateAction.Invoke(false); 211 | } 212 | } 213 | } 214 | else 215 | { 216 | _ovr.Init(); 217 | Thread.Sleep(1000); 218 | } 219 | } 220 | } 221 | 222 | // Controller roles have updated, refresh controller handles 223 | private void UpdateInputSourceHandles() 224 | { 225 | _inputSourceHandleLeft = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.LeftHand); 226 | _inputSourceHandleRight = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.RightHand); 227 | ulong index = 0L; 228 | _inputSourceHandles[index++] = _inputSourceHandleLeft; 229 | _inputSourceHandles[index++] = _inputSourceHandleRight; 230 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.Head); 231 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.Chest); 232 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.LeftShoulder); 233 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.LeftElbow); 234 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.LeftKnee); 235 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.LeftFoot); 236 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.RightShoulder); 237 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.RightElbow); 238 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.RightKnee); 239 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.RightFoot); 240 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.Waist); 241 | _inputSourceHandles[index++] = _ovr.GetInputSourceHandle(EasyOpenVRSingleton.InputSource.Camera); 242 | } 243 | 244 | // New app is running, distribute new app ID 245 | private void UpdateAppId() 246 | { 247 | StopRegisteringKeys(); 248 | _currentApplicationId = _ovr.GetRunningApplicationId(); 249 | if (_currentApplicationId == string.Empty) _currentApplicationId = MainModel.CONFIG_DEFAULT; 250 | AppUpdateAction.Invoke(_currentApplicationId); 251 | LoadConfig(); 252 | } 253 | 254 | // Load config, if it exists 255 | public void LoadConfig(bool forceDefault=false) 256 | { 257 | var configName = forceDefault ? MainModel.CONFIG_DEFAULT : _currentApplicationId; 258 | var config = MainModel.RetrieveConfig(configName); 259 | if (config != null) MainModel.SetConfigName(configName); 260 | Debug.WriteLine($"Config for {configName} found: {config != null}"); 261 | ConfigRetrievedAction.Invoke(config, _currentApplicationId == MainModel.CONFIG_DEFAULT); 262 | } 263 | 264 | public bool AppIsRunning() 265 | { 266 | Debug.WriteLine($"Running app: {_currentApplicationId}"); 267 | return _currentApplicationId != MainModel.CONFIG_DEFAULT; 268 | } 269 | #endregion 270 | 271 | #region actions 272 | public void OpenConfigFolder() // TODO: This refuses to open the right folder so the button is hidden. 273 | { 274 | var folderPath = MainModel.GetConfigFolderPath(); 275 | if (Directory.Exists(folderPath)) 276 | { 277 | ProcessStartInfo startInfo = new ProcessStartInfo 278 | { 279 | Arguments = folderPath, 280 | FileName = "explorer.exe" 281 | }; 282 | Process.Start(startInfo); 283 | } else 284 | { 285 | MessageBox.Show("Folder does not exist yet as no config has been saved."); 286 | } 287 | } 288 | #endregion 289 | 290 | #region vr_input 291 | 292 | // Register all actions with the input system 293 | private void RegisterActions() 294 | { 295 | _ovr.RegisterActionSet("/actions/keys"); 296 | foreach (var actionKey in _actionKeys) 297 | { 298 | var localActionKey = actionKey; 299 | _ovr.RegisterDigitalAction($"/actions/keys/in/Key{actionKey}", (data, inputAction) => { OnAction(localActionKey, data, inputAction.handle); }, actionKey.Contains("C")); 300 | } 301 | } 302 | 303 | // Action was triggered, handle it 304 | private void OnAction(string actionKey, InputDigitalActionData_t data, ulong inputSourceHandle) 305 | { 306 | KeyActivatedAction.Invoke(actionKey, data.bState); 307 | Debug.WriteLine($"{actionKey} : " + (data.bState ? "PRESSED" : "RELEASED")); 308 | if (MainModel.BindingExists(actionKey)) 309 | { 310 | var binding = MainModel.GetBinding(actionKey); 311 | if (data.bState) 312 | { 313 | if (MainModel.LoadSetting(MainModel.Setting.Haptic)) 314 | { 315 | if (inputSourceHandle == _inputSourceHandleLeft) _ovr.TriggerHapticPulseInController(ETrackedControllerRole.LeftHand); 316 | if (inputSourceHandle == _inputSourceHandleRight) _ovr.TriggerHapticPulseInController(ETrackedControllerRole.RightHand); 317 | } 318 | if (MainModel.LoadSetting(MainModel.Setting.Notification)) 319 | { 320 | var notificationBitmap = BitmapUtils.NotificationBitmapFromBitmap(Properties.Resources.logo); 321 | _ovr.EnqueueNotification(_notificationOverlayHandle, $"{actionKey} simulated {GetKeysLabel(binding.Item1)}", notificationBitmap); 322 | } 323 | } 324 | SimulateKeyPress(data, binding); 325 | } 326 | } 327 | #endregion 328 | 329 | #region keyboard_out 330 | 331 | // Simulate a keyboard press 332 | private void SimulateKeyPress(InputDigitalActionData_t data, Tuple binding) 333 | { 334 | if (data.bState) 335 | { 336 | foreach (var vk in binding.Item2) _sim.Keyboard.KeyDown(vk); 337 | foreach (var vk in binding.Item3) _sim.Keyboard.KeyDown(vk); 338 | } 339 | else 340 | { 341 | foreach (var vk in binding.Item3) _sim.Keyboard.KeyUp(vk); 342 | foreach (var vk in binding.Item2) _sim.Keyboard.KeyUp(vk); 343 | } 344 | } 345 | #endregion 346 | 347 | public void LaunchBindings() 348 | { 349 | OpenVR.Input.OpenBindingUI("", 0, 0, true); 350 | } 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /OpenVR2Key/MainModel.cs: -------------------------------------------------------------------------------- 1 | using GregsStack.InputSimulatorStandard.Native; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Text.RegularExpressions; 6 | using System.Windows.Input; 7 | using System.Text.Json; 8 | 9 | namespace OpenVR2Key 10 | { 11 | static class MainModel 12 | { 13 | #region bindings 14 | public static readonly string CONFIG_DEFAULT = "default"; 15 | private static readonly object _bindingsLock = new object(); 16 | private static Dictionary> _bindings = new Dictionary>(); 17 | 18 | /** 19 | * Store key codes as virtual key codes. 20 | */ 21 | static public void RegisterBinding(string actionKey, HashSet keys) 22 | { 23 | var keysArr = new Key[keys.Count]; 24 | keys.CopyTo(keysArr); 25 | var binding = MainUtils.ConvertKeys(keysArr); 26 | lock (_bindingsLock) 27 | { 28 | _bindings[actionKey] = binding; 29 | var config = new Dictionary(); 30 | foreach (var key in _bindings.Keys) 31 | { 32 | config.Add(key, _bindings[key].Item1); 33 | } 34 | StoreConfig(config); 35 | } 36 | } 37 | static private void RegisterBindings(Dictionary config) 38 | { 39 | var bindings = new Dictionary>(); 40 | foreach (var key in config.Keys) 41 | { 42 | var keys = config[key]; 43 | var binding = MainUtils.ConvertKeys(keys); 44 | bindings[key] = binding; 45 | } 46 | lock (_bindingsLock) 47 | { 48 | _bindings = bindings; 49 | } 50 | } 51 | static public bool BindingExists(string actionKey) 52 | { 53 | lock (_bindingsLock) 54 | { 55 | return _bindings.ContainsKey(actionKey); 56 | } 57 | } 58 | public static Tuple GetBinding(string actionkey) 59 | { 60 | lock (_bindingsLock) 61 | { 62 | return _bindings[actionkey]; 63 | } 64 | } 65 | static public void ClearBindings() 66 | { 67 | lock (_bindingsLock) 68 | { 69 | _bindings.Clear(); 70 | } 71 | StoreConfig(); 72 | } 73 | static public void RemoveBinding(string actionKey) 74 | { 75 | lock (_bindingsLock) 76 | { 77 | _bindings.Remove(actionKey); 78 | var config = new Dictionary(); 79 | foreach (var key in _bindings.Keys) 80 | { 81 | config.Add(key, _bindings[key].Item1); 82 | } 83 | StoreConfig(config); 84 | } 85 | } 86 | #endregion 87 | 88 | #region config 89 | static private string _configName = CONFIG_DEFAULT; 90 | 91 | static public void SetConfigName(string configName) 92 | { 93 | CleanConfigName(ref configName); 94 | _configName = configName; 95 | } 96 | 97 | static public bool IsDefaultConfig() 98 | { 99 | return _configName == CONFIG_DEFAULT; 100 | } 101 | 102 | static private void CleanConfigName(ref string configName) 103 | { 104 | Regex rgx = new Regex(@"[^a-zA-Z0-9\.]"); 105 | var cleaned = rgx.Replace(configName, String.Empty).Trim(new char[] { '.' }); 106 | configName = cleaned == String.Empty ? CONFIG_DEFAULT : cleaned; 107 | } 108 | 109 | static public string GetConfigFolderPath() 110 | { 111 | return $"{Directory.GetCurrentDirectory()}\\config\\"; 112 | } 113 | 114 | static public void StoreConfig(Dictionary config = null, string configName = null) 115 | { 116 | if (config == null) 117 | { 118 | config = new Dictionary(); 119 | lock (_bindingsLock) 120 | { 121 | foreach (var key in _bindings.Keys) 122 | { 123 | config.Add(key, _bindings[key].Item1); 124 | } 125 | } 126 | } 127 | if (configName == null) configName = _configName; 128 | var jsonString = JsonSerializer.Serialize(config); 129 | var configDir = GetConfigFolderPath(); 130 | var configFilePath = $"{configDir}{configName}.json"; 131 | if (!Directory.Exists(configDir)) Directory.CreateDirectory(configDir); 132 | File.WriteAllText(configFilePath, jsonString); 133 | } 134 | 135 | static public void DeleteConfig(string configName = null) 136 | { 137 | if (configName == null) configName = _configName; 138 | var configDir = GetConfigFolderPath(); 139 | var configFilePath = $"{configDir}{configName}.json"; 140 | if(File.Exists(configFilePath)) 141 | { 142 | File.Delete(configFilePath); 143 | _configName = CONFIG_DEFAULT; 144 | } 145 | } 146 | 147 | static public Dictionary RetrieveConfig(string configName = null) 148 | { 149 | if (configName == null) configName = _configName; 150 | CleanConfigName(ref configName); 151 | var configDir = $"{Directory.GetCurrentDirectory()}\\config\\"; 152 | var configFilePath = $"{configDir}{configName}.json"; 153 | var jsonString = File.Exists(configFilePath) ? File.ReadAllText(configFilePath) : null; 154 | if (jsonString != null) 155 | { 156 | var config = JsonSerializer.Deserialize(jsonString, typeof(Dictionary)) as Dictionary; 157 | RegisterBindings(config); 158 | return config; 159 | } 160 | return null; 161 | } 162 | #endregion 163 | 164 | #region Settings 165 | public enum Setting 166 | { 167 | Minimize, Tray, Notification, Haptic, ExitWithSteam 168 | } 169 | 170 | private static readonly Properties.Settings p = Properties.Settings.Default; 171 | 172 | static public void UpdateSetting(Setting setting, bool value) 173 | { 174 | var propertyName = Enum.GetName(typeof(Setting), setting); 175 | p[propertyName] = value; 176 | p.Save(); 177 | } 178 | 179 | static public bool LoadSetting(Setting setting) 180 | { 181 | var propertyName = Enum.GetName(typeof(Setting), setting); 182 | return (bool)p[propertyName]; 183 | } 184 | 185 | static public string GetVersion() 186 | { 187 | return (string)Properties.Resources.Version; 188 | } 189 | #endregion 190 | 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /OpenVR2Key/MainUtils.cs: -------------------------------------------------------------------------------- 1 | using GregsStack.InputSimulatorStandard.Native; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.Windows.Input; 6 | 7 | namespace OpenVR2Key 8 | { 9 | public static class MainUtils 10 | { 11 | private static Dictionary translationTableModifiers = new Dictionary() { 12 | { Key.LeftAlt, VirtualKeyCode.LMENU }, 13 | { Key.RightAlt, VirtualKeyCode.RMENU }, 14 | { Key.LeftCtrl, VirtualKeyCode.LCONTROL }, 15 | { Key.RightCtrl, VirtualKeyCode.RCONTROL }, 16 | { Key.LeftShift, VirtualKeyCode.LSHIFT }, 17 | { Key.RightShift, VirtualKeyCode.RSHIFT }, 18 | { Key.System, VirtualKeyCode.MENU } 19 | }; 20 | private static Dictionary translationTableKeys = new Dictionary() 21 | { 22 | { Key.PageUp, VirtualKeyCode.PRIOR }, 23 | { Key.PageDown, VirtualKeyCode.NEXT }, 24 | { Key.MediaNextTrack, VirtualKeyCode.MEDIA_NEXT_TRACK }, 25 | { Key.MediaPreviousTrack, VirtualKeyCode.MEDIA_PREV_TRACK }, 26 | { Key.MediaPlayPause, VirtualKeyCode.MEDIA_PLAY_PAUSE }, 27 | { Key.MediaStop, VirtualKeyCode.MEDIA_STOP }, 28 | { Key.SelectMedia, VirtualKeyCode.LAUNCH_MEDIA_SELECT }, 29 | { Key.VolumeMute, VirtualKeyCode.VOLUME_MUTE }, 30 | { Key.VolumeUp, VirtualKeyCode.VOLUME_UP }, 31 | { Key.VolumeDown, VirtualKeyCode.VOLUME_DOWN }, 32 | { Key.OemClear, VirtualKeyCode.OEM_CLEAR}, 33 | { Key.OemComma, VirtualKeyCode.OEM_COMMA }, 34 | { Key.OemMinus, VirtualKeyCode.OEM_MINUS}, 35 | { Key.OemPeriod, VirtualKeyCode.OEM_PERIOD}, 36 | { Key.OemPlus, VirtualKeyCode.OEM_PLUS}, 37 | /* 38 | * References for virtual key codes. 39 | * https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes 40 | * https://sites.google.com/site/douglaslash/Home/programming/c-notes--snippets/c-keycodes 41 | * http://www.kbdedit.com/manual/low_level_vk_list.html 42 | */ 43 | { Key.OemOpenBrackets, VirtualKeyCode.OEM_4}, 44 | { Key.OemQuestion, VirtualKeyCode.OEM_2}, 45 | { Key.OemQuotes, VirtualKeyCode.OEM_7}, 46 | { Key.OemBackslash, VirtualKeyCode.OEM_102} 47 | }; 48 | 49 | /** 50 | * Match a key code against a virtual key code, also check if modifier 51 | */ 52 | public static Tuple MatchVirtualKey(Key key) 53 | { 54 | Debug.WriteLine(key.ToString()); 55 | var keyStr = key.ToString().ToUpper(); 56 | 57 | // Check for direct translation 58 | if (translationTableKeys.ContainsKey(key)) 59 | { 60 | return new Tuple(translationTableKeys[key], false); 61 | } 62 | 63 | // Check for translation as modifier key 64 | if (translationTableModifiers.ContainsKey(key)) 65 | { 66 | return new Tuple(translationTableModifiers[key], true); 67 | } 68 | 69 | // Character keys which come in as A-Z 70 | if (keyStr.Length == 1) keyStr = $"VK_{keyStr}"; 71 | 72 | // Number keys which come in as D0-D9 73 | else if (keyStr.Length == 2 && keyStr[0] == 'D' && Char.IsDigit(keyStr[1])) 74 | { 75 | keyStr = $"VK_{keyStr[1]}"; 76 | } 77 | // OEM Number keys (these are weird and some require direct mapping from translation dictionaries translationTables above) 78 | else if (keyStr.StartsWith("OEM") && (int.TryParse(keyStr.Substring(3), out _))) 79 | { 80 | keyStr = $"OEM_{keyStr.Substring(3)}"; 81 | } 82 | var success = Enum.TryParse(keyStr, out VirtualKeyCode result); 83 | if (!success) 84 | { 85 | Debug.WriteLine("Key not found."); 86 | } 87 | return success ? new Tuple(result, false) : null; 88 | // If no key found, returns null 89 | } 90 | 91 | /** 92 | * Match incoming key codes to virtual ones, sort out modifiers 93 | */ 94 | public static Tuple ConvertKeys(Key[] keys) 95 | { 96 | var vModifiers = new List(); 97 | var vKeys = new List(); 98 | for (var i = 0; i < keys.Length; i++) 99 | { 100 | var match = MatchVirtualKey(keys[i]); 101 | if (match != null) 102 | { 103 | if (match.Item2) vModifiers.Add(match.Item1); 104 | else vKeys.Add(match.Item1); 105 | } 106 | } 107 | return new Tuple(keys, vModifiers.ToArray(), vKeys.ToArray()); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /OpenVR2Key/MainWindow.xaml: -------------------------------------------------------------------------------- 1 |  8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Read Help 22 | 23 | 24 | Report issue 25 | 26 | 27 | Join Discord 28 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |