├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 20-error-report.yml │ └── config.yml ├── dependabot.yml └── workflows │ └── ci-build.yml ├── .gitignore ├── LICENSE ├── MigrationInstaller ├── MigrationInstaller.csproj └── Program.cs ├── README.md ├── ShockOsc.slnx ├── ShockOsc ├── Config │ ├── BehaviourConf.cs │ ├── BoneAction.cs │ ├── ChatboxConf.cs │ ├── Group.cs │ ├── JsonRange.cs │ ├── MedalIcymi.cs │ ├── OscConf.cs │ ├── SharedBehaviourConfig.cs │ └── ShockOscConfig.cs ├── Models │ ├── ProgramGroup.cs │ └── TriggerMethod.cs ├── OscChangeTracker │ ├── ChangeTrackedOscParam.cs │ └── IChangeTrackedOscParam.cs ├── Resources │ └── ShockOSC-Icon.svg ├── Services │ ├── ChatboxService.cs │ ├── MedalIcymiService.cs │ ├── OscClient.cs │ ├── OscHandler.cs │ ├── ShockOsc.cs │ ├── ShockOscData.cs │ └── UnderscoreConfig.cs ├── ShockOSCModule.cs ├── ShockOsc.csproj ├── Ui │ ├── Pages │ │ └── Dash │ │ │ └── Tabs │ │ │ ├── ChatboxTab.razor │ │ │ ├── ConfigTab.razor │ │ │ ├── DebugTab.razor │ │ │ └── GroupsTab.razor │ ├── Utils │ │ ├── DebouncedSlider.razor │ │ └── DebouncedSlider.razor.cs │ └── _Imports.razor └── Utils │ ├── MathUtils.cs │ └── OsTask.cs └── copy-module-dll.cmd /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: openshock 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20-error-report.yml: -------------------------------------------------------------------------------- 1 | name: 'Error Report' 2 | description: "An unhandled error occoured in ShockOSC" 3 | title: '[Error] ' 4 | labels: ['type: error', 'status: triage'] 5 | projects: ['OpenShock/6'] 6 | 7 | body: 8 | 9 | - type: markdown 10 | attributes: 11 | value: | 12 | # Checklist 13 | 14 | - type: checkboxes 15 | id: checklist 16 | attributes: 17 | label: Pre-submission checklist 18 | description: | 19 | To prevent wasting your or our time, please fill out the below checklist before continuing. 20 | Thanks for understanding! 21 | options: 22 | - label: 'I checked that no other Error or Bug Report describing my problem exists.' 23 | required: true 24 | - label: 'I am running the latest stable or prerelease version of ShockOSC.' 25 | required: true 26 | - label: 'I accept that this issue may be closed if any of the above are found to be untrue.' 27 | required: true 28 | 29 | - type: markdown 30 | attributes: 31 | value: | 32 | # Board & Firmware 33 | 34 | - type: dropdown 35 | id: os 36 | attributes: 37 | label: OS 38 | description: What Operating System are you running? 39 | options: 40 | - Windows 10 41 | - Windows 11 42 | - Linux 43 | - Other (please mention below) 44 | validations: 45 | required: True 46 | 47 | - type: markdown 48 | attributes: 49 | value: | 50 | # Error / Exception Stack Trace 51 | 52 | - type: textarea 53 | id: exception 54 | attributes: 55 | label: 'Paste your error / exception stack trace here' 56 | validations: 57 | required: true 58 | 59 | - type: input 60 | id: shockosc-version 61 | attributes: 62 | label: 'ShockOSC version' 63 | description: Which ShockOSC version did you use? 64 | placeholder: 'E.g.: 1.2.4, 1.0.0-rc.4..' 65 | validations: 66 | required: true 67 | 68 | - type: textarea 69 | id: what-happened 70 | attributes: 71 | label: 'Describe what you were doing when the error occoured as precisely as possible.' 72 | validations: 73 | required: true 74 | 75 | - type: markdown 76 | attributes: 77 | value: | 78 | # Steps to reproduce 79 | 80 | - type: textarea 81 | id: how-to-reproduce 82 | attributes: 83 | label: 'Reproduction Steps' 84 | description: 'If you can reproduce the error, describe the exact steps you took to make the problem appear.' 85 | 86 | validations: 87 | required: false 88 | 89 | - type: markdown 90 | attributes: 91 | value: | 92 | # Anything else? 93 | 94 | - type: textarea 95 | id: anything-else 96 | attributes: 97 | label: 'Other remarks' 98 | validations: 99 | required: false 100 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "nuget" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - '**' 5 | pull_request: 6 | branches: 7 | - '**' 8 | types: [opened, reopened, synchronize] 9 | workflow_call: 10 | workflow_dispatch: 11 | 12 | name: ci-build 13 | 14 | env: 15 | DOTNET_VERSION: 9.0.x 16 | REGISTRY: ghcr.io 17 | 18 | jobs: 19 | 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: ${{ env.DOTNET_VERSION }} 31 | 32 | - name: Cache NuGet packages 33 | uses: actions/cache@v3 34 | with: 35 | path: ~/.nuget/packages 36 | key: ${{ runner.os }}-nuget 37 | restore-keys: | 38 | ${{ runner.os }}-nuget 39 | 40 | - name: Publish ShockOSC 41 | run: dotnet publish ShockOsc/ShockOsc.csproj -c Release -o ./publish/ 42 | 43 | - name: Upload ShockOSC artifacts 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: ShockOsc 47 | path: publish/* 48 | retention-days: 1 49 | if-no-files-found: error -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .idea 4 | *.DotSettings.user 5 | .vs 6 | ShockOsc_Setup.exe -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /MigrationInstaller/MigrationInstaller.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0-windows 6 | enable 7 | enable 8 | true 9 | win-x64 10 | Size 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /MigrationInstaller/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Diagnostics; 3 | using System.Security.Principal; 4 | using Microsoft.Win32; 5 | 6 | namespace MigrationInstaller; 7 | 8 | public static class Program 9 | { 10 | private const string AppDisplayName = "ShockOSC"; 11 | private const string DownloadUrl = "https://github.com/OpenShock/Desktop/releases/download/1.0.0-preview.4/OpenShock_Desktop_Setup.exe"; 12 | private static string TempInstallerPath => Path.Combine(Path.GetTempPath(), "OpenShock_Desktop_Setup.exe"); 13 | 14 | public static async Task Main() 15 | { 16 | try 17 | { 18 | if (await RunMainLogic()) return; 19 | } 20 | catch (Exception ex) 21 | { 22 | Console.WriteLine("❌ Error: " + ex.Message); 23 | } 24 | 25 | Console.WriteLine("Press enter to exit."); 26 | Console.ReadLine(); 27 | } 28 | 29 | private static bool RelaunchAsAdmin() 30 | { 31 | // Relaunch with admin rights 32 | var processInfo = new ProcessStartInfo 33 | { 34 | FileName = Environment.ProcessPath, 35 | UseShellExecute = true, 36 | Verb = "runas" 37 | }; 38 | 39 | try 40 | { 41 | Process.Start(processInfo); 42 | return true; 43 | } 44 | catch 45 | { 46 | Console.WriteLine("User denied elevation."); 47 | return false; 48 | } 49 | } 50 | 51 | private static async Task RunMainLogic() 52 | { 53 | if(!IsRunAsAdmin()) 54 | { 55 | Console.WriteLine("❌ Please run this program as administrator."); 56 | return RelaunchAsAdmin(); 57 | } 58 | 59 | Console.WriteLine("🔍 Searching for uninstaller..."); 60 | var uninstallerPath = FindUninstaller(AppDisplayName); 61 | if (string.IsNullOrEmpty(uninstallerPath)) 62 | { 63 | Console.WriteLine("❌ Uninstaller not found."); 64 | return false; 65 | } 66 | 67 | Console.WriteLine($"🗑 Running uninstaller: {uninstallerPath}"); 68 | RunProcess(uninstallerPath, "/S"); 69 | 70 | Console.WriteLine("🌐 Downloading new installer..."); 71 | await DownloadFile(DownloadUrl, TempInstallerPath); 72 | 73 | Console.WriteLine("🚀 Launching new installer..."); 74 | RunProcess(TempInstallerPath, ""); 75 | 76 | Console.WriteLine("✅ Update process complete."); 77 | return true; 78 | } 79 | 80 | private static readonly ImmutableArray RegistryPaths = 81 | [ 82 | @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", 83 | @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" 84 | ]; 85 | 86 | private static readonly ImmutableArray Keys = 87 | [ 88 | Registry.LocalMachine, 89 | Registry.CurrentUser 90 | ]; 91 | 92 | private static string? FindUninstaller(string displayName) 93 | { 94 | foreach (var registryKey in Keys) 95 | { 96 | foreach (var reg in RegistryPaths) 97 | { 98 | using var key = registryKey.OpenSubKey(reg); 99 | if (key == null) continue; 100 | var result = CheckForUninstallString(displayName, key); 101 | if (!string.IsNullOrEmpty(result)) return result; 102 | } 103 | } 104 | 105 | return null; 106 | } 107 | 108 | private static string? CheckForUninstallString(string displayName, RegistryKey? key) 109 | { 110 | if (key == null) return null; // continue; 111 | foreach (var subkeyName in key.GetSubKeyNames()) 112 | { 113 | using var subkey = key.OpenSubKey(subkeyName); 114 | if(subkey?.GetValue("DisplayName") is not string regDisplayName) continue; 115 | if (!regDisplayName.Equals(displayName, StringComparison.InvariantCulture)) continue; 116 | if(subkey.GetValue("UninstallString") is not string regUninstallString) continue; 117 | return regUninstallString; 118 | } 119 | 120 | return null; 121 | } 122 | 123 | private static async Task DownloadFile(string url, string destinationPath) 124 | { 125 | Console.WriteLine($"Downloading from {url} to {destinationPath}"); 126 | using var client = new HttpClient(); 127 | using var response = await client.GetAsync(url); 128 | response.EnsureSuccessStatusCode(); 129 | 130 | await using var fs = new FileStream(destinationPath, FileMode.Create, FileAccess.Write); 131 | await response.Content.CopyToAsync(fs); 132 | } 133 | 134 | private static void RunProcess(string file, string args) 135 | { 136 | var proc = new Process(); 137 | proc.StartInfo.FileName = file; 138 | proc.StartInfo.Arguments = args; 139 | proc.StartInfo.UseShellExecute = false; 140 | proc.StartInfo.CreateNoWindow = true; 141 | proc.Start(); 142 | proc.WaitForExit(); 143 | } 144 | 145 | private static bool IsRunAsAdmin() 146 | { 147 | using var identity = WindowsIdentity.GetCurrent(); 148 | var principal = new WindowsPrincipal(identity); 149 | return principal.IsInRole(WindowsBuiltInRole.Administrator); 150 | } 151 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | OpenShock Logo 4 | 5 |

ShockOSC

6 | 7 | [![Release Version](https://img.shields.io/github/v/release/OpenShock/ShockOsc?style=for-the-badge&color=e14a6d)](https://github.com/OpenShock/ShockOsc/releases/latest) 8 | [![Downloads](https://img.shields.io/github/downloads/OpenShock/ShockOsc/total?style=for-the-badge&color=e14a6d)](https://github.com/OpenShock/ShockOsc/releases/latest) 9 | [![Discord](https://img.shields.io/discord/1078124408775901204?style=for-the-badge&color=e14a6d&label=OpenShock%20Discord&logo=discord)](https://openshock.net/discord) 10 | 11 | ![ShockOsc](https://sea.zlucplayz.com/f/72732636ab0743c6b365/?raw=1) 12 | 13 | Used as an interface for OpenShock to communicate with applications that support OSC and OscQuery like VRChat. 14 | 15 |
16 | 17 | ## Setup 18 | 19 | [Wiki](https://wiki.openshock.org/guides/shockosc-basic/) 20 | 21 | ### Visual parameters 22 | 23 | You can add some optional parameters to your avatar to visualize when the shocker is active or on cooldown. 24 | Add these parameters to your avatars animator & params file. 25 | 26 | - **bool** `ShockOsc/{GroupName}_Active` enabled only while the shocker is active 27 | - **bool** `ShockOsc/{GroupName}_Cooldown` enabled only while the shocker isn't active and on cooldown 28 | - **float** `ShockOsc/{GroupName}_CooldownPercentage` 0f = shocker isn't on cooldown, 1f = shocker on cooldown (0f while shocker is active) 29 | - **float** `ShockOsc/{GroupName}_Intensity` 0..1f percentage value that represents how close the shock was to maximum intensity from `IntensityRange` (except for FixedIntensity) 30 | 31 | #### Virtual Groups (visual) 32 | 33 | You can use the virtual, or pseudo, shockers with the name `_Any` and `_All` for some limited actions. Read more below. 34 | 35 | ##### `_Any` 36 | - `ShockOsc/_Any_Active` is true whenever there is any shocker currently **shocking** 37 | - `ShockOsc/_Any_Cooldown` is true whenever there is any shocker currently **on cooldown** 38 | 39 | ##### `_All` 40 | This one can be used to make all shockers configured go off at the same time or with the same trigger. 41 | This virtual shocker behaves just like another configured shockers, except it relays its actions to all others. 42 | 43 | #### Instant Shocker Action 44 | You may append `_IShock` to a shocker parameter if u want a shock to trigger **instantly** when this bool parameter jumps to true. 45 | This is useful when working with an animator setup or have contact receivers trigger immediately. 46 | 47 | E.g. `ShockOsc/_All_IShock` 48 | 49 | 50 | ## Credits 51 | 52 | [ShockOsc Contributors](https://github.com/OpenShock/ShockOsc/graphs/contributors) 53 | 54 | ## Support 55 | 56 | You can support the openshock dev team here: [Sponsor OpenShock](https://github.com/sponsors/OpenShock) 57 | -------------------------------------------------------------------------------- /ShockOsc.slnx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ShockOsc/Config/BehaviourConf.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.Config; 2 | 3 | public sealed class BehaviourConf : SharedBehaviourConfig 4 | { 5 | public uint HoldTime { get; set; } = 250; 6 | public bool DisableWhileAfk { get; set; } = true; 7 | public bool ForceUnmute { get; set; } 8 | } -------------------------------------------------------------------------------- /ShockOsc/Config/BoneAction.cs: -------------------------------------------------------------------------------- 1 | using OpenShock.Desktop.ModuleBase.Models; 2 | 3 | namespace OpenShock.ShockOSC.Config; 4 | 5 | public enum BoneAction 6 | { 7 | None = 0, 8 | Shock = 1, 9 | Vibrate = 2, 10 | Sound = 3 11 | } 12 | 13 | public static class BoneActionExtensions 14 | { 15 | public static readonly BoneAction[] BoneActions = Enum.GetValues(typeof(BoneAction)).Cast().ToArray(); 16 | 17 | public static ControlType ToControlType(this BoneAction action) 18 | { 19 | return action switch 20 | { 21 | BoneAction.Shock => ControlType.Shock, 22 | BoneAction.Vibrate => ControlType.Vibrate, 23 | BoneAction.Sound => ControlType.Sound, 24 | _ => ControlType.Vibrate 25 | }; 26 | } 27 | } -------------------------------------------------------------------------------- /ShockOsc/Config/ChatboxConf.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using OpenShock.Desktop.ModuleBase.Models; 3 | 4 | namespace OpenShock.ShockOSC.Config; 5 | 6 | public sealed class ChatboxConf 7 | { 8 | public bool Enabled { get; set; } = true; 9 | public string Prefix { get; set; } = "[ShockOSC] "; 10 | public bool DisplayRemoteControl { get; set; } = true; 11 | 12 | public bool TimeoutEnabled { get; set; } = true; 13 | public uint Timeout { get; set; } = 5000; 14 | 15 | [JsonIgnore] 16 | public TimeSpan TimeoutTimeSpan 17 | { 18 | get => TimeSpan.FromMilliseconds(Timeout); 19 | set => Timeout = (uint)value.TotalMilliseconds; 20 | } 21 | 22 | public HoscyMessageType HoscyType { get; set; } = HoscyMessageType.Message; 23 | 24 | public string IgnoredKillSwitchActive { get; set; } = "Ignoring Shock, kill switch is active"; 25 | public string IgnoredGroupPauseActive { get; set; } = "Ignoring Shock, {GroupName} is paused"; 26 | public string IgnoredAfk { get; set; } = "Ignoring Shock, user is afk"; 27 | 28 | public IDictionary Types { get; set; } = 29 | new Dictionary 30 | { 31 | { 32 | ControlType.Stop, new ControlTypeConf 33 | { 34 | Enabled = true, 35 | Local = "⏸ '{GroupName}'", 36 | Remote = "⏸ '{ShockerName}' by {Name}", 37 | RemoteWithCustomName = "⏸ '{ShockerName}' by {CustomName} [{Name}]" 38 | } 39 | }, 40 | { 41 | ControlType.Shock, new ControlTypeConf 42 | { 43 | Enabled = true, 44 | Local = "⚡ '{GroupName}' {Intensity}%:{DurationSeconds}s", 45 | Remote = "⚡ '{ShockerName}' {Intensity}%:{DurationSeconds}s by {Name}", 46 | RemoteWithCustomName = 47 | "⚡ '{ShockerName}' {Intensity}%:{DurationSeconds}s by {CustomName} [{Name}]" 48 | } 49 | }, 50 | { 51 | ControlType.Vibrate, new ControlTypeConf 52 | { 53 | Enabled = true, 54 | Local = "〜 '{GroupName}' {Intensity}%:{DurationSeconds}s", 55 | Remote = "〜 '{ShockerName}' {Intensity}%:{DurationSeconds}s by {Name}", 56 | RemoteWithCustomName = 57 | "〜 '{ShockerName}' {Intensity}%:{DurationSeconds}s by {CustomName} [{Name}]" 58 | } 59 | }, 60 | { 61 | ControlType.Sound, new ControlTypeConf 62 | { 63 | Enabled = true, 64 | Local = "🔈 '{GroupName}' {Intensity}%:{DurationSeconds}s", 65 | Remote = "🔈 '{ShockerName}' {Intensity}%:{DurationSeconds}s by {Name}", 66 | RemoteWithCustomName = 67 | "🔈 '{ShockerName}' {Intensity}%:{DurationSeconds}s by {CustomName} [{Name}]" 68 | } 69 | } 70 | }; 71 | 72 | public sealed class ControlTypeConf 73 | { 74 | public required bool Enabled { get; set; } 75 | public required string Local { get; set; } 76 | public required string Remote { get; set; } 77 | public required string RemoteWithCustomName { get; set; } 78 | } 79 | 80 | public enum HoscyMessageType 81 | { 82 | Message, 83 | Notification 84 | } 85 | } -------------------------------------------------------------------------------- /ShockOsc/Config/Group.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.Config; 2 | 3 | public sealed class Group : SharedBehaviourConfig 4 | { 5 | public required string Name { get; set; } 6 | public IList Shockers { get; set; } = new List(); 7 | public bool OverrideIntensity { get; set; } 8 | public bool OverrideDuration { get; set; } 9 | public bool OverrideCooldownTime { get; set; } 10 | 11 | public bool OverrideBoneHeldAction { get; set; } 12 | public bool OverrideBoneReleasedAction { get; set; } 13 | 14 | public bool OverrideBoneHeldDurationLimit { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /ShockOsc/Config/JsonRange.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Global 2 | namespace OpenShock.ShockOSC.Config; 3 | 4 | public class JsonRange where T : struct 5 | { 6 | public required T Min { get; set; } 7 | public required T Max { get; set; } 8 | } -------------------------------------------------------------------------------- /ShockOsc/Config/MedalIcymi.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable InconsistentNaming 2 | namespace OpenShock.ShockOSC.Config; 3 | 4 | public sealed class MedalIcymi 5 | { 6 | public bool Enabled { get; set; } = false; 7 | public string Name { get; set; } = "ShockOSC"; 8 | public string Description { get; set; } = "ShockOSC activated."; 9 | public int ClipDuration { get; set; } = 30; 10 | public IcymiAlertType AlertType { get; set; } = IcymiAlertType.Default; 11 | public IcymiTriggerAction TriggerAction { get; set; } = IcymiTriggerAction.SaveClip; 12 | public IcymiGame Game { get; set; } = IcymiGame.VRChat; 13 | } 14 | 15 | public enum IcymiTriggerAction 16 | { 17 | SaveClip 18 | } 19 | 20 | public enum IcymiAlertType 21 | { 22 | Default, 23 | Disabled, 24 | SoundOnly, 25 | OverlayOnly 26 | } 27 | 28 | public enum IcymiGame 29 | { 30 | VRChat, 31 | ChilloutVR 32 | } -------------------------------------------------------------------------------- /ShockOsc/Config/OscConf.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.Config; 2 | 3 | public sealed class OscConf 4 | { 5 | public bool Hoscy { get; set; } = false; 6 | public ushort HoscySendPort { get; set; } = 9001; 7 | public bool QuestSupport { get; set; } = false; 8 | public bool OscQuery { get; set; } = true; 9 | public ushort OscSendPort { get; set; } = 9000; 10 | public ushort OscReceivePort { get; set; } = 9001; 11 | } -------------------------------------------------------------------------------- /ShockOsc/Config/SharedBehaviourConfig.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.Config; 2 | 3 | public class SharedBehaviourConfig 4 | { 5 | public bool RandomIntensity { get; set; } 6 | public bool RandomDuration { get; set; } 7 | 8 | public JsonRange DurationRange { get; set; } = new JsonRange { Min = 1000, Max = 5000 }; 9 | public JsonRange IntensityRange { get; set; } = new JsonRange { Min = 1, Max = 30 }; 10 | public byte FixedIntensity { get; set; } = 50; 11 | public ushort FixedDuration { get; set; } = 2000; 12 | 13 | public uint CooldownTime { get; set; } = 5000; 14 | 15 | public BoneAction WhileBoneHeld { get; set; } = BoneAction.Vibrate; 16 | public BoneAction WhenBoneReleased { get; set; } = BoneAction.Shock; 17 | 18 | public uint? BoneHeldDurationLimit { get; set; } = null; 19 | } -------------------------------------------------------------------------------- /ShockOsc/Config/ShockOscConfig.cs: -------------------------------------------------------------------------------- 1 | using OpenShock.ShockOSC.Models; 2 | 3 | namespace OpenShock.ShockOSC.Config; 4 | 5 | public sealed class ShockOscConfig 6 | { 7 | public MedalIcymi MedalIcymi { get; set; } = new(); 8 | public OscConf Osc { get; set; } = new(); 9 | public BehaviourConf Behaviour { get; set; } = new(); 10 | public ChatboxConf Chatbox { get; set; } = new(); 11 | public IDictionary Groups { get; set; } = new Dictionary(); 12 | 13 | public T GetGroupOrGlobal(ProgramGroup group, Func selector, Func groupOverrideSelector) 14 | { 15 | if(group.ConfigGroup == null) return selector(Behaviour); 16 | 17 | var groupOverride = groupOverrideSelector(group.ConfigGroup); 18 | SharedBehaviourConfig config = groupOverride ? group.ConfigGroup : Behaviour; 19 | return selector(config); 20 | } 21 | } -------------------------------------------------------------------------------- /ShockOsc/Models/ProgramGroup.cs: -------------------------------------------------------------------------------- 1 | using OpenShock.Desktop.ModuleBase.Models; 2 | using OpenShock.ShockOSC.Config; 3 | using OpenShock.ShockOSC.OscChangeTracker; 4 | using OpenShock.ShockOSC.Services; 5 | 6 | namespace OpenShock.ShockOSC.Models; 7 | 8 | public sealed class ProgramGroup 9 | { 10 | public bool Paused { get; set; } = false; 11 | public DateTime LastActive { get; set; } 12 | public DateTime LastExecuted { get; set; } 13 | public DateTime LastHeldAction { get; set; } 14 | public DateTime? PhysBoneGrabLimitTime { get; set; } 15 | public ushort LastDuration { get; set; } 16 | public byte LastIntensity { get; set; } 17 | public float LastStretchValue { get; set; } 18 | public bool IsGrabbed { get; set; } 19 | 20 | /// 21 | /// Scaled to 0-100 22 | /// 23 | public byte NextIntensity { get; set; } = 0; 24 | 25 | /// 26 | /// Not scaled, 0-1 float, needs to be scaled to duration limits 27 | /// 28 | public float NextDuration { get; set; } = 0; 29 | 30 | public ChangeTrackedOscParam ParamActive { get; } 31 | public ChangeTrackedOscParam ParamCooldown { get; } 32 | public ChangeTrackedOscParam ParamCooldownPercentage { get; } 33 | public ChangeTrackedOscParam ParamIntensity { get; } 34 | 35 | public byte LastConcurrentIntensity { get; set; } = 0; 36 | public byte ConcurrentIntensity { get; set; } = 0; 37 | public ControlType ConcurrentType { get; set; } = ControlType.Stop; 38 | 39 | public Guid Id { get; } 40 | public string Name { get; } 41 | public TriggerMethod TriggerMethod { get; set; } 42 | 43 | public Group? ConfigGroup { get; } 44 | 45 | public ProgramGroup(Guid id, string name, OscClient oscClient, Group? group) 46 | { 47 | Id = id; 48 | Name = name; 49 | ConfigGroup = group; 50 | 51 | ParamActive = new ChangeTrackedOscParam(Name, "_Active", false, oscClient); 52 | ParamCooldown = new ChangeTrackedOscParam(Name, "_Cooldown", false, oscClient); 53 | ParamCooldownPercentage = new ChangeTrackedOscParam(Name, "_CooldownPercentage", 0f, oscClient); 54 | ParamIntensity = new ChangeTrackedOscParam(Name, "_Intensity", 0f, oscClient); 55 | } 56 | 57 | public void Reset() 58 | { 59 | IsGrabbed = false; 60 | LastStretchValue = 0; 61 | ConcurrentType = ControlType.Stop; 62 | ConcurrentIntensity = 0; 63 | LastConcurrentIntensity = 0; 64 | NextIntensity = 0; 65 | NextDuration = 0; 66 | } 67 | } -------------------------------------------------------------------------------- /ShockOsc/Models/TriggerMethod.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.Models; 2 | 3 | public enum TriggerMethod 4 | { 5 | None, 6 | Manual 7 | } -------------------------------------------------------------------------------- /ShockOsc/OscChangeTracker/ChangeTrackedOscParam.cs: -------------------------------------------------------------------------------- 1 | using OpenShock.ShockOSC.Services; 2 | using Serilog; 3 | using ILogger = Serilog.ILogger; 4 | 5 | namespace OpenShock.ShockOSC.OscChangeTracker; 6 | 7 | public class ChangeTrackedOscParam : IChangeTrackedOscParam 8 | { 9 | private readonly OscClient _oscClient; 10 | 11 | // ReSharper disable once StaticMemberInGenericType 12 | private static readonly ILogger Logger = Log.ForContext(typeof(ChangeTrackedOscParam<>)); 13 | 14 | public string Address { get; } 15 | public T Value { get; private set; } 16 | 17 | public ChangeTrackedOscParam(string address, T initialValue, OscClient oscClient) 18 | { 19 | _oscClient = oscClient; 20 | Address = address; 21 | Value = initialValue; 22 | } 23 | 24 | public ChangeTrackedOscParam(string shockerName, string suffix, T initialValue, OscClient oscClient) : this( 25 | $"/avatar/parameters/ShockOsc/{shockerName}{suffix}", initialValue, oscClient) 26 | { 27 | } 28 | 29 | public ValueTask Send() 30 | { 31 | Logger.Debug("Sending parameter update for [{ParameterAddress}] with value [{Value}]", Address, Value); 32 | return _oscClient.SendGameMessage(Address, Value); 33 | } 34 | 35 | public ValueTask SetValue(T value) 36 | { 37 | if (Value!.Equals(value)) return ValueTask.CompletedTask; 38 | Value = value; 39 | return Send(); 40 | } 41 | } -------------------------------------------------------------------------------- /ShockOsc/OscChangeTracker/IChangeTrackedOscParam.cs: -------------------------------------------------------------------------------- 1 | namespace OpenShock.ShockOSC.OscChangeTracker; 2 | 3 | public interface IChangeTrackedOscParam 4 | { 5 | public ValueTask Send(); 6 | } -------------------------------------------------------------------------------- /ShockOsc/Resources/ShockOSC-Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OSC 30 | -------------------------------------------------------------------------------- /ShockOsc/Services/ChatboxService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Channels; 2 | using Microsoft.Extensions.Logging; 3 | using OpenShock.Desktop.ModuleBase.Config; 4 | using OpenShock.Desktop.ModuleBase.Models; 5 | using OpenShock.ShockOSC.Config; 6 | using OpenShock.ShockOSC.Models; 7 | using OpenShock.ShockOSC.Utils; 8 | using SmartFormat; 9 | 10 | namespace OpenShock.ShockOSC.Services; 11 | 12 | /// 13 | /// Handle chatbox interactions and behaviour 14 | /// 15 | public sealed class ChatboxService : IAsyncDisposable 16 | { 17 | private readonly IModuleConfig _moduleConfig; 18 | private readonly OscClient _oscClient; 19 | private readonly ILogger _logger; 20 | private readonly System.Threading.Timer _clearTimer; 21 | 22 | private readonly CancellationTokenSource _cts = new(); 23 | 24 | private readonly Channel _messageChannel = Channel.CreateBounded(new BoundedChannelOptions(4) 25 | { 26 | SingleReader = true, 27 | FullMode = BoundedChannelFullMode.DropOldest 28 | }); 29 | 30 | public ChatboxService(IModuleConfig moduleConfig, OscClient oscClient, ILogger logger) 31 | { 32 | _moduleConfig = moduleConfig; 33 | _oscClient = oscClient; 34 | _logger = logger; 35 | 36 | _clearTimer = new System.Threading.Timer(ClearChatbox); 37 | 38 | OsTask.Run(MessageLoop); 39 | } 40 | 41 | private async void ClearChatbox(object? state) 42 | { 43 | try 44 | { 45 | await _oscClient.SendChatboxMessage(string.Empty); 46 | _logger.LogTrace("Cleared chatbox"); 47 | } 48 | catch (Exception e) 49 | { 50 | _logger.LogError(e, "Failed to send clear chatbox"); 51 | } 52 | } 53 | 54 | public async ValueTask SendLocalControlMessage(string name, byte intensity, uint duration, ControlType type) 55 | { 56 | if (!_moduleConfig.Config.Chatbox.Enabled) return; 57 | 58 | if (!_moduleConfig.Config.Chatbox.Types.TryGetValue(type, out var template)) 59 | { 60 | _logger.LogError("No message template found for control type {ControlType}", type); 61 | return; 62 | } 63 | 64 | if (!template.Enabled) return; 65 | 66 | // Chatbox message local 67 | var dat = new 68 | { 69 | GroupName = name, 70 | ShockerName = name, 71 | Intensity = intensity, 72 | Duration = duration, 73 | DurationSeconds = duration.DurationInSecondsString() 74 | }; 75 | 76 | var msg = $"{_moduleConfig.Config.Chatbox.Prefix}{Smart.Format(template.Local, dat)}"; 77 | 78 | await _messageChannel.Writer.WriteAsync(new Message(msg, _moduleConfig.Config.Chatbox.TimeoutTimeSpan)); 79 | } 80 | 81 | public async ValueTask SendRemoteControlMessage(string shockerName, string senderName, string? customName, 82 | byte intensity, uint duration, ControlType type) 83 | { 84 | if (!_moduleConfig.Config.Chatbox.Enabled || !_moduleConfig.Config.Chatbox.DisplayRemoteControl) return; 85 | 86 | if (!_moduleConfig.Config.Chatbox.Types.TryGetValue(type, out var template)) 87 | { 88 | _logger.LogError("No message template found for control type {ControlType}", type); 89 | return; 90 | } 91 | 92 | if (!template.Enabled) return; 93 | 94 | // Chatbox message remote 95 | var dat = new 96 | { 97 | ShockerName = shockerName, 98 | Intensity = intensity, 99 | Duration = duration, 100 | DurationSeconds = duration.DurationInSecondsString(), 101 | Name = senderName, 102 | CustomName = customName 103 | }; 104 | 105 | var templateToUse = customName == null ? template.Remote : template.RemoteWithCustomName; 106 | 107 | var msg = $"{_moduleConfig.Config.Chatbox.Prefix}{Smart.Format(templateToUse, dat)}"; 108 | 109 | await _messageChannel.Writer.WriteAsync(new Message(msg, _moduleConfig.Config.Chatbox.TimeoutTimeSpan)); 110 | } 111 | 112 | public async ValueTask SendGroupPausedMessage(ProgramGroup programGroup) 113 | { 114 | if (!_moduleConfig.Config.Chatbox.Enabled) return; 115 | 116 | var dat = new 117 | { 118 | GroupName = programGroup.Name 119 | }; 120 | 121 | var msg = $"{_moduleConfig.Config.Chatbox.Prefix}{Smart.Format(_moduleConfig.Config.Chatbox.IgnoredGroupPauseActive, dat)}"; 122 | 123 | await _messageChannel.Writer.WriteAsync(new Message(msg, _moduleConfig.Config.Chatbox.TimeoutTimeSpan)); 124 | } 125 | 126 | public async ValueTask SendGenericMessage(string message) 127 | { 128 | if (!_moduleConfig.Config.Chatbox.Enabled) return; 129 | 130 | var msg = $"{_moduleConfig.Config.Chatbox.Prefix}{message}"; 131 | await _messageChannel.Writer.WriteAsync(new Message(msg, _moduleConfig.Config.Chatbox.TimeoutTimeSpan)); 132 | } 133 | 134 | private async Task MessageLoop() 135 | { 136 | await foreach (var message in _messageChannel.Reader.ReadAllAsync()) 137 | { 138 | await _oscClient.SendChatboxMessage(message.Text); 139 | 140 | if(_moduleConfig.Config.Osc.Hoscy) continue; 141 | // We dont need to worry about timeouts if we're using hoscy 142 | if(_moduleConfig.Config.Chatbox.TimeoutEnabled) _clearTimer.Change(message.Timeout, Timeout.InfiniteTimeSpan); 143 | await Task.Delay(1250); // VRChat chatbox rate limit 144 | } 145 | } 146 | 147 | private bool _disposed; 148 | 149 | public async ValueTask DisposeAsync() 150 | { 151 | if (_disposed) return; 152 | _disposed = true; 153 | 154 | await _clearTimer.DisposeAsync(); 155 | 156 | await _cts.CancelAsync(); 157 | _cts.Dispose(); 158 | 159 | GC.SuppressFinalize(this); 160 | } 161 | 162 | ~ChatboxService() 163 | { 164 | if (_disposed) return; 165 | DisposeAsync().AsTask().Wait(); 166 | } 167 | } 168 | 169 | public record Message(string Text, TimeSpan Timeout); -------------------------------------------------------------------------------- /ShockOsc/Services/MedalIcymiService.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Microsoft.Extensions.Logging; 4 | using OpenShock.Desktop.ModuleBase.Config; 5 | using OpenShock.ShockOSC.Config; 6 | 7 | namespace OpenShock.ShockOSC.Services; 8 | 9 | public class MedalIcymiService 10 | { 11 | private readonly ILogger _logger; 12 | private readonly IModuleConfig _moduleConfig; 13 | private static readonly HttpClient HttpClient = new(); 14 | private const string BaseUrl = "http://localhost:12665/api/v1"; 15 | // these are publicly generated and are not sensitive. 16 | private const string PubApiKeyVrc = "pub_x4PTxSGVk6sl8BYg5EB5qsn8QIVz4kRi"; 17 | private const string PubApiKeyCvr = "pub_LRG3bA6XjoVSkSU4JuXmL51tJdGJWdVQ"; 18 | 19 | public MedalIcymiService(ILogger logger, IModuleConfig moduleConfig) 20 | { 21 | _logger = logger; 22 | _moduleConfig = moduleConfig; 23 | switch (_moduleConfig.Config.MedalIcymi.Game) 24 | { 25 | case IcymiGame.VRChat: 26 | HttpClient.DefaultRequestHeaders.Add("publicKey", PubApiKeyVrc); 27 | break; 28 | case IcymiGame.ChilloutVR: 29 | HttpClient.DefaultRequestHeaders.Add("publicKey", PubApiKeyCvr); 30 | break; 31 | default: 32 | _logger.LogError("Game Selection was out of range. Value was: {value}", _moduleConfig.Config.MedalIcymi.Game); 33 | break; 34 | } 35 | } 36 | 37 | public async Task TriggerMedalIcymiAction(string eventId) 38 | { 39 | var eventPayload = new 40 | { 41 | eventId, 42 | eventName = _moduleConfig.Config.MedalIcymi.Name, 43 | 44 | contextTags = new 45 | { 46 | location = _moduleConfig.Config.MedalIcymi.Game.ToString(), 47 | description = _moduleConfig.Config.MedalIcymi.Description 48 | }, 49 | triggerActions = new[] 50 | { 51 | _moduleConfig.Config.MedalIcymi.TriggerAction.ToString() 52 | }, 53 | 54 | clipOptions = new 55 | { 56 | duration = _moduleConfig.Config.MedalIcymi.ClipDuration, 57 | alertType = _moduleConfig.Config.MedalIcymi.AlertType.ToString(), 58 | } 59 | }; 60 | 61 | var jsonPayload = JsonSerializer.Serialize(eventPayload); 62 | var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); 63 | 64 | try 65 | { 66 | var response = await HttpClient.PostAsync($"{BaseUrl}/event/invoke", content); 67 | 68 | _logger.LogInformation("{triggerAction} triggered.", _moduleConfig.Config.MedalIcymi.TriggerAction); 69 | 70 | var responseContent = await response.Content.ReadAsStringAsync(); 71 | HandleApiResponse((int)response.StatusCode, responseContent); 72 | } 73 | catch (Exception ex) 74 | { 75 | _logger.LogError("Error while creating Medal {triggerAction}: {exception}", _moduleConfig.Config.MedalIcymi.TriggerAction, ex); 76 | } 77 | } 78 | 79 | private void HandleApiResponse(int statusCode, string responseContent) 80 | { 81 | switch (statusCode) 82 | { 83 | case 400 when responseContent.Contains("INVALID_MODEL"): 84 | _logger.LogError("Invalid model: The request body does not match the expected model structure."); 85 | break; 86 | 87 | case 400 when responseContent.Contains("INVALID_EVENT"): 88 | _logger.LogError("Invalid event: The provided game event details are invalid."); 89 | break; 90 | 91 | case 400 when responseContent.Contains("MISSING_PUBLIC_KEY"): 92 | _logger.LogError("Missing public key: The publicKey header is missing from the request."); 93 | break; 94 | 95 | case 400 when responseContent.Contains("INVALID_APP_DATA"): 96 | _logger.LogError("Invalid app data: Failed to retrieve app data associated with the provided public key."); 97 | break; 98 | 99 | case 200 when responseContent.Contains("INACTIVE_GAME"): 100 | _logger.LogWarning("Inactive game: The event was received but not processed because the categoryId does not match the active game."); 101 | break; 102 | 103 | case 200 when responseContent.Contains("DISABLED_EVENT"): 104 | _logger.LogWarning("Disabled event: The event was received but not processed because it is disabled in the user’s ICYMI settings."); 105 | break; 106 | 107 | case 200 when responseContent.Contains("success"): 108 | _logger.LogDebug("Event received and processed successfully"); 109 | break; 110 | 111 | case 500: 112 | _logger.LogError("Internal server error: An unexpected error occurred while processing the request."); 113 | break; 114 | 115 | default: 116 | _logger.LogWarning("Unexpected response: {statusCode} - {response}", statusCode, responseContent); 117 | break; 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /ShockOsc/Services/OscClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Channels; 3 | using LucHeart.CoreOSC; 4 | using Microsoft.Extensions.Logging; 5 | using OpenShock.Desktop.ModuleBase.Config; 6 | using OpenShock.ShockOSC.Config; 7 | 8 | namespace OpenShock.ShockOSC.Services; 9 | 10 | public sealed class OscClient 11 | { 12 | private readonly ILogger _logger; 13 | private readonly IModuleConfig _moduleConfig; 14 | private OscDuplex? _gameConnection; 15 | private readonly OscSender _hoscySenderClient; 16 | 17 | public OscClient(ILogger logger, IModuleConfig moduleConfig) 18 | { 19 | _logger = logger; 20 | _moduleConfig = moduleConfig; 21 | _hoscySenderClient = new OscSender(new IPEndPoint(IPAddress.Loopback, _moduleConfig.Config.Osc.HoscySendPort)); 22 | 23 | Task.Run(GameSenderLoop); 24 | Task.Run(HoscySenderLoop); 25 | } 26 | 27 | public void CreateGameConnection(IPAddress ipAddress, ushort receivePort, ushort sendPort) 28 | { 29 | _gameConnection?.Dispose(); 30 | _gameConnection = null; 31 | _logger.LogDebug("Creating game connection with receive port {ReceivePort} and send port {SendPort}", receivePort, sendPort); 32 | _gameConnection = new(new IPEndPoint(ipAddress, receivePort), new IPEndPoint(ipAddress, sendPort)); 33 | } 34 | 35 | private readonly Channel _gameSenderChannel = Channel.CreateUnbounded(new UnboundedChannelOptions 36 | { 37 | SingleReader = true 38 | }); 39 | 40 | private readonly Channel _hoscySenderChannel = Channel.CreateUnbounded(new UnboundedChannelOptions 41 | { 42 | SingleReader = true 43 | }); 44 | 45 | public ValueTask SendGameMessage(string address, params object?[]?arguments) 46 | { 47 | arguments ??= []; 48 | return _gameSenderChannel.Writer.WriteAsync(new OscMessage(address, arguments)); 49 | } 50 | 51 | public ValueTask SendChatboxMessage(string message) 52 | { 53 | if (_moduleConfig.Config.Osc.Hoscy) return _hoscySenderChannel.Writer.WriteAsync(new OscMessage( 54 | $"/hoscy/{_moduleConfig.Config.Chatbox.HoscyType.ToString().ToLowerInvariant()}", message)); 55 | return _gameSenderChannel.Writer.WriteAsync(new OscMessage("/chatbox/input", message, true)); 56 | } 57 | 58 | private async Task GameSenderLoop() 59 | { 60 | _logger.LogDebug("Starting game sender loop"); 61 | await foreach (var oscMessage in _gameSenderChannel.Reader.ReadAllAsync()) 62 | { 63 | if (_gameConnection == null) continue; 64 | try 65 | { 66 | await _gameConnection.SendAsync(oscMessage); 67 | } 68 | catch (Exception e) 69 | { 70 | _logger.LogError(e, "GameSenderClient send failed"); 71 | } 72 | } 73 | } 74 | 75 | private async Task HoscySenderLoop() 76 | { 77 | _logger.LogDebug("Starting hoscy sender loop"); 78 | await foreach (var oscMessage in _hoscySenderChannel.Reader.ReadAllAsync()) 79 | { 80 | try 81 | { 82 | await _hoscySenderClient.SendAsync(oscMessage); 83 | } 84 | catch (Exception e) 85 | { 86 | _logger.LogError(e, "HoscySenderClient send failed"); 87 | } 88 | } 89 | } 90 | 91 | public Task? ReceiveGameMessage() 92 | { 93 | return _gameConnection?.ReceiveMessageAsync(); 94 | } 95 | } -------------------------------------------------------------------------------- /ShockOsc/Services/OscHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using OpenShock.Desktop.ModuleBase.Config; 3 | using OpenShock.ShockOSC.Config; 4 | using OpenShock.ShockOSC.OscChangeTracker; 5 | using OpenShock.ShockOSC.Utils; 6 | 7 | namespace OpenShock.ShockOSC.Services; 8 | 9 | public sealed class OscHandler 10 | { 11 | private readonly ChangeTrackedOscParam _paramAnyActive; 12 | private readonly ChangeTrackedOscParam _paramAnyCooldown; 13 | private readonly ChangeTrackedOscParam _paramAnyCooldownPercentage; 14 | private readonly ChangeTrackedOscParam _paramAnyIntensity; 15 | 16 | private readonly ILogger _logger; 17 | private readonly OscClient _oscClient; 18 | private readonly IModuleConfig _moduleConfig; 19 | private readonly ShockOscData _shockOscData; 20 | 21 | public OscHandler(ILogger logger, OscClient oscClient, IModuleConfig moduleConfig, ShockOscData shockOscData) 22 | { 23 | _logger = logger; 24 | _oscClient = oscClient; 25 | _moduleConfig = moduleConfig; 26 | _shockOscData = shockOscData; 27 | 28 | _paramAnyActive = new ChangeTrackedOscParam("_Any", "_Active", false, _oscClient); 29 | _paramAnyCooldown = new ChangeTrackedOscParam("_Any", "_Cooldown", false, _oscClient); 30 | _paramAnyCooldownPercentage = new ChangeTrackedOscParam("_Any", "_CooldownPercentage", 0f, _oscClient); 31 | _paramAnyIntensity = new ChangeTrackedOscParam("_Any", "_Intensity", 0f, _oscClient); 32 | } 33 | 34 | /// 35 | /// Force unmute the users if enabled in config 36 | /// 37 | public async Task ForceUnmute() 38 | { 39 | // If we don't have to force unmute or we're not muted, also check config here. 40 | if (!_moduleConfig.Config.Behaviour.ForceUnmute || !_shockOscData.IsMuted) return; 41 | 42 | _logger.LogDebug("Force unmuting..."); 43 | 44 | // So this is absolutely disgusting, but vrchat seems to be very retarded. 45 | // PS: If you send true for more than 500ms the game locks up. 46 | 47 | // Button press off 48 | await _oscClient.SendGameMessage("/input/Voice", false) 49 | .ConfigureAwait(false); 50 | 51 | // We wait 50 ms.. 52 | await Task.Delay(50) 53 | .ConfigureAwait(false); 54 | 55 | // Button press on 56 | await _oscClient.SendGameMessage("/input/Voice", true) 57 | .ConfigureAwait(false); 58 | 59 | // We wait 50 ms.. 60 | await Task.Delay(50) 61 | .ConfigureAwait(false); 62 | 63 | // Button press off 64 | await _oscClient.SendGameMessage("/input/Voice", false) 65 | .ConfigureAwait(false); 66 | } 67 | 68 | /// 69 | /// Send parameter updates to osc 70 | /// 71 | public async Task SendParams() 72 | { 73 | // TODO: maybe force resend on avatar change 74 | var anyActive = false; 75 | var anyCooldown = false; 76 | var anyCooldownPercentage = 0f; 77 | var anyIntensity = 0f; 78 | 79 | foreach (var shocker in _shockOscData.ProgramGroups.Values) 80 | { 81 | var isActive = shocker.LastExecuted.AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow; 82 | var isActiveOrOnCooldown = 83 | shocker.LastExecuted.AddMilliseconds(_moduleConfig.Config.Behaviour.CooldownTime) 84 | .AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow; 85 | if (!isActiveOrOnCooldown && shocker.LastIntensity > 0) 86 | shocker.LastIntensity = 0; 87 | 88 | var intensity = MathUtils.Saturate(shocker.LastIntensity / 100f); 89 | var onCoolDown = !isActive && isActiveOrOnCooldown; 90 | var cooldownPercentage = 0f; 91 | if (onCoolDown) 92 | cooldownPercentage = MathUtils.Saturate(1 - 93 | (float)(DateTime.UtcNow - 94 | shocker.LastExecuted.AddMilliseconds(shocker.LastDuration)) 95 | .TotalMilliseconds / 96 | _moduleConfig.Config.Behaviour.CooldownTime); 97 | 98 | await shocker.ParamActive.SetValue(isActive); 99 | await shocker.ParamCooldown.SetValue(onCoolDown); 100 | await shocker.ParamCooldownPercentage.SetValue(cooldownPercentage); 101 | await shocker.ParamIntensity.SetValue(intensity); 102 | 103 | if (isActive) anyActive = true; 104 | if (onCoolDown) anyCooldown = true; 105 | anyCooldownPercentage = MathF.Max(anyCooldownPercentage, cooldownPercentage); 106 | anyIntensity = MathF.Max(anyIntensity, intensity); 107 | } 108 | 109 | await _paramAnyActive.SetValue(anyActive); 110 | await _paramAnyCooldown.SetValue(anyCooldown); 111 | await _paramAnyCooldownPercentage.SetValue(anyCooldownPercentage); 112 | await _paramAnyIntensity.SetValue(anyIntensity); 113 | } 114 | } -------------------------------------------------------------------------------- /ShockOsc/Services/ShockOsc.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Net; 3 | using System.Reactive.Subjects; 4 | using LucHeart.CoreOSC; 5 | using Microsoft.Extensions.Logging; 6 | using MudBlazor.Extensions; 7 | using OneOf.Types; 8 | using OpenShock.Desktop.ModuleBase.Api; 9 | using OpenShock.Desktop.ModuleBase.Config; 10 | using OpenShock.Desktop.ModuleBase.Models; 11 | using OpenShock.MinimalEvents; 12 | using OpenShock.ShockOSC.Config; 13 | using OpenShock.ShockOSC.Models; 14 | using OpenShock.ShockOSC.Utils; 15 | using OscQueryLibrary; 16 | using OscQueryLibrary.Utils; 17 | 18 | #pragma warning disable CS4014 19 | 20 | namespace OpenShock.ShockOSC.Services; 21 | 22 | public sealed class ShockOsc 23 | { 24 | private readonly ILogger _logger; 25 | private readonly OscClient _oscClient; 26 | private readonly IOpenShockService _openShockService; 27 | private readonly MedalIcymiService _medalIcymiService; 28 | private readonly UnderscoreConfig _underscoreConfig; 29 | private readonly IModuleConfig _moduleConfig; 30 | private readonly OscQueryServer _oscQueryServer; 31 | private readonly ShockOscData _dataLayer; 32 | private readonly OscHandler _oscHandler; 33 | private readonly ChatboxService _chatboxService; 34 | 35 | private bool _oscServerActive; 36 | private bool _isAfk; 37 | public string AvatarId = string.Empty; 38 | private readonly Random Random = new(); 39 | 40 | private readonly MinimalEvent _onGroupsChanged = new(); 41 | 42 | private static readonly string[] ShockerParams = 43 | [ 44 | string.Empty, 45 | "Stretch", 46 | "IsGrabbed", 47 | "Cooldown", 48 | "Active", 49 | "Intensity", 50 | "CooldownPercentage", 51 | "IShock", 52 | "IVibrate", 53 | "ISound", 54 | "CShock", 55 | "CVibrate", 56 | "CSound", 57 | "NextIntensity", 58 | "NextDuration" 59 | ]; 60 | 61 | public readonly Dictionary ShockOscParams = new(); 62 | public readonly Dictionary AllAvatarParams = new(); 63 | 64 | public IObservable OnParamsChangeObservable => _onParamsChange; 65 | private readonly Subject _onParamsChange = new(); 66 | 67 | public ShockOsc(ILogger logger, 68 | OscClient oscClient, 69 | IOpenShockService openShockService, 70 | UnderscoreConfig underscoreConfig, 71 | IModuleConfig moduleConfig, 72 | OscQueryServer oscQueryServer, 73 | ShockOscData dataLayer, 74 | OscHandler oscHandler, 75 | ChatboxService chatboxService, 76 | MedalIcymiService medalIcymiService) 77 | { 78 | _logger = logger; 79 | _oscClient = oscClient; 80 | _openShockService = openShockService; 81 | _underscoreConfig = underscoreConfig; 82 | _moduleConfig = moduleConfig; 83 | _oscQueryServer = oscQueryServer; 84 | _dataLayer = dataLayer; 85 | _oscHandler = oscHandler; 86 | _chatboxService = chatboxService; 87 | _medalIcymiService = medalIcymiService; 88 | 89 | _onGroupsChanged.Subscribe(SetupGroups); 90 | 91 | oscQueryServer.FoundVrcClient.SubscribeAsync(endPoint => SetupVrcClient((oscQueryServer, endPoint))).AsTask() 92 | .Wait(); 93 | oscQueryServer.ParameterUpdate.SubscribeAsync(OnAvatarChange).AsTask().Wait(); 94 | 95 | SetupGroups(); 96 | } 97 | 98 | public async Task Start() 99 | { 100 | if (!_moduleConfig.Config.Osc.OscQuery) 101 | { 102 | await SetupVrcClient(null); 103 | } 104 | } 105 | 106 | private void SetupGroups() 107 | { 108 | _dataLayer.ProgramGroups.Clear(); 109 | _dataLayer.ProgramGroups[Guid.Empty] = new ProgramGroup(Guid.Empty, "_All", _oscClient, null); 110 | foreach (var (id, group) in _moduleConfig.Config.Groups) 111 | _dataLayer.ProgramGroups[id] = new ProgramGroup(id, group.Name, _oscClient, group); 112 | } 113 | 114 | public void RaiseOnGroupsChanged() => _onGroupsChanged.Invoke(); 115 | 116 | private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) 117 | { 118 | // stop tasks 119 | _oscServerActive = false; 120 | await Task.Delay(1000); // wait for tasks to stop TODO: REWORK THIS 121 | 122 | if (client != null) 123 | { 124 | _logger.LogInformation("Found VRC client at {Ip}", client.Value.Item2); 125 | _oscClient.CreateGameConnection(client.Value.Item2.Address, client.Value.Item1.OscReceivePort, 126 | (ushort)client.Value.Item2.Port); 127 | } 128 | else 129 | { 130 | _oscClient.CreateGameConnection(IPAddress.Loopback, _moduleConfig.Config.Osc.OscReceivePort, 131 | _moduleConfig.Config.Osc.OscSendPort); 132 | } 133 | 134 | _logger.LogInformation("Connecting UDP Clients..."); 135 | 136 | // Start tasks 137 | _oscServerActive = true; 138 | OsTask.Run(ReceiverLoopAsync); 139 | OsTask.Run(SenderLoopAsync); 140 | OsTask.Run(CheckLoop); 141 | 142 | _logger.LogInformation("Ready"); 143 | OsTask.Run(_underscoreConfig.SendUpdateForAll); 144 | 145 | await _chatboxService.SendGenericMessage("Game Connected"); 146 | } 147 | 148 | private Task OnAvatarChange(OscQueryServer.ParameterUpdateArgs parameterUpdateArgs) 149 | { 150 | AvatarId = parameterUpdateArgs.AvatarId; 151 | var parameters = parameterUpdateArgs.Parameters; 152 | try 153 | { 154 | foreach (var obj in _dataLayer.ProgramGroups) 155 | { 156 | obj.Value.Reset(); 157 | } 158 | 159 | var parameterCount = 0; 160 | 161 | ShockOscParams.Clear(); 162 | AllAvatarParams.Clear(); 163 | 164 | foreach (var param in parameters.Keys) 165 | { 166 | if (param.StartsWith("/avatar/parameters/")) 167 | AllAvatarParams.TryAdd(param[19..], parameters[param]); 168 | 169 | if (!param.StartsWith("/avatar/parameters/ShockOsc/")) 170 | continue; 171 | 172 | var paramName = param[28..]; 173 | var lastUnderscoreIndex = paramName.LastIndexOf('_') + 1; 174 | var shockerName = paramName; 175 | // var action = string.Empty; 176 | if (lastUnderscoreIndex > 1) 177 | { 178 | shockerName = paramName[..(lastUnderscoreIndex - 1)]; 179 | // action = paramName.Substring(lastUnderscoreIndex, paramName.Length - lastUnderscoreIndex); 180 | } 181 | 182 | parameterCount++; 183 | ShockOscParams.TryAdd(param[28..], parameters[param]); 184 | 185 | if (!_dataLayer.ProgramGroups.Any(x => 186 | x.Value.Name.Equals(shockerName, StringComparison.InvariantCultureIgnoreCase)) && 187 | !shockerName.StartsWith('_')) 188 | { 189 | _logger.LogWarning("Unknown shocker on avatar {Shocker}", shockerName); 190 | _logger.LogDebug("Param: {Param}", param); 191 | } 192 | } 193 | 194 | _logger.LogInformation("Loaded avatar config with {ParamCount} parameters", parameterCount); 195 | } 196 | catch (Exception e) 197 | { 198 | _logger.LogError(e, "Error on avatar change logic"); 199 | } 200 | 201 | _onParamsChange.OnNext(true); 202 | return Task.CompletedTask; 203 | } 204 | 205 | private async Task ReceiverLoopAsync() 206 | { 207 | while (_oscServerActive) 208 | { 209 | try 210 | { 211 | await ReceiveLogic(); 212 | } 213 | catch (Exception e) 214 | { 215 | _logger.LogError(e, "Error in receiver loop"); 216 | } 217 | } 218 | // ReSharper disable once FunctionNeverReturns 219 | } 220 | 221 | private async Task ReceiveLogic() 222 | { 223 | OscMessage received; 224 | try 225 | { 226 | received = await _oscClient.ReceiveGameMessage()!; 227 | } 228 | catch (Exception e) 229 | { 230 | _logger.LogTrace(e, "Error receiving message"); 231 | return; 232 | } 233 | 234 | var addr = received.Address; 235 | 236 | if (addr.StartsWith("/avatar/parameters/")) 237 | { 238 | // FIXME: less alloc pls 239 | var fullName = addr[19..]; 240 | if (AllAvatarParams.ContainsKey(fullName)) 241 | AllAvatarParams[fullName] = received.Arguments[0]; 242 | else 243 | AllAvatarParams.TryAdd(fullName, received.Arguments[0]); 244 | _onParamsChange.OnNext(false); 245 | } 246 | 247 | switch (addr) 248 | { 249 | case "/avatar/change": 250 | var avatarId = received.Arguments.ElementAtOrDefault(0); 251 | _logger.LogDebug("Avatar changed: {AvatarId}", avatarId); 252 | OsTask.Run(_oscQueryServer.RefreshParameters); 253 | OsTask.Run(_underscoreConfig.SendUpdateForAll); 254 | return; 255 | case "/avatar/parameters/AFK": 256 | _isAfk = received.Arguments.ElementAtOrDefault(0) is true; 257 | _logger.LogDebug("Afk: {State}", _isAfk); 258 | return; 259 | case "/avatar/parameters/MuteSelf": 260 | _dataLayer.IsMuted = received.Arguments.ElementAtOrDefault(0) is true; 261 | _logger.LogDebug("Muted: {State}", _dataLayer.IsMuted); 262 | return; 263 | } 264 | 265 | if (!addr.StartsWith("/avatar/parameters/ShockOsc/")) 266 | return; 267 | 268 | var pos = addr.Substring(28, addr.Length - 28); 269 | 270 | if (ShockOscParams.ContainsKey(pos)) 271 | { 272 | ShockOscParams[pos] = received.Arguments[0]; 273 | _onParamsChange.OnNext(true); 274 | } 275 | else 276 | ShockOscParams.TryAdd(pos, received.Arguments[0]); 277 | 278 | // Check if _Config 279 | if (pos.StartsWith("_Config/")) 280 | { 281 | _underscoreConfig.HandleCommand(pos, received.Arguments); 282 | return; 283 | } 284 | 285 | var lastUnderscoreIndex = pos.LastIndexOf('_') + 1; 286 | var action = string.Empty; 287 | var groupName = pos; 288 | if (lastUnderscoreIndex > 1) 289 | { 290 | groupName = pos[..(lastUnderscoreIndex - 1)]; 291 | action = pos.Substring(lastUnderscoreIndex, pos.Length - lastUnderscoreIndex); 292 | } 293 | 294 | if (!ShockerParams.Contains(action)) return; 295 | 296 | if (!_dataLayer.ProgramGroups.Any(x => 297 | x.Value.Name.Equals(groupName, StringComparison.InvariantCultureIgnoreCase))) 298 | { 299 | if (groupName == "_Any") return; 300 | _logger.LogWarning("Unknown group {GroupName}", groupName); 301 | _logger.LogDebug("Param: {Param}", pos); 302 | return; 303 | } 304 | 305 | var programGroup = _dataLayer.ProgramGroups 306 | .First(x => x.Value.Name.Equals(groupName, StringComparison.InvariantCultureIgnoreCase)).Value; 307 | 308 | var value = received.Arguments.ElementAtOrDefault(0); 309 | switch (action) 310 | { 311 | case "NextIntensity": 312 | if (value is not float nextIntensity) 313 | { 314 | programGroup.NextIntensity = 0; 315 | return; 316 | } 317 | 318 | programGroup.NextIntensity = Convert.ToByte(MathUtils.Saturate(nextIntensity) * 100f); 319 | break; 320 | 321 | case "NextDuration": 322 | if (value is not float nextDuration) 323 | { 324 | programGroup.NextDuration = 0; 325 | return; 326 | } 327 | 328 | programGroup.NextDuration = nextDuration; 329 | break; 330 | 331 | case "CShock": 332 | case "CVibrate": 333 | case "CSound": 334 | if (value is not float intensity) 335 | { 336 | programGroup.ConcurrentIntensity = 0; 337 | programGroup.ConcurrentType = ControlType.Stop; 338 | return; 339 | } 340 | 341 | var scaledIntensity = MathUtils.Saturate(intensity) * 100f; 342 | programGroup.ConcurrentIntensity = Convert.ToByte(scaledIntensity); 343 | 344 | var ctype = action switch 345 | { 346 | "CShock" => ControlType.Shock, 347 | "CVibrate" => ControlType.Vibrate, 348 | "CSound" => ControlType.Sound, 349 | _ => ControlType.Vibrate 350 | }; 351 | 352 | programGroup.ConcurrentType = ctype; 353 | break; 354 | 355 | case "IShock": 356 | case "IVibrate": 357 | case "ISound": 358 | if (value is not true) return; 359 | 360 | if (!await HandlePrecondition(CheckAndSetAllPreconditions(programGroup), programGroup)) return; 361 | 362 | 363 | var type = action switch 364 | { 365 | "IShock" => ControlType.Shock, 366 | "IVibrate" => ControlType.Vibrate, 367 | "ISound" => ControlType.Sound, 368 | _ => ControlType.Vibrate 369 | }; 370 | 371 | OsTask.Run(() => 372 | SendCommand(programGroup, GetDuration(programGroup), GetIntensity(programGroup), type)); 373 | 374 | return; 375 | case "Stretch": 376 | if (value is float stretch) 377 | programGroup.LastStretchValue = stretch; 378 | return; 379 | case "IsGrabbed": 380 | var isGrabbed = value is true; 381 | await PhysboneHandling(programGroup, isGrabbed); 382 | 383 | programGroup.IsGrabbed = isGrabbed; 384 | return; 385 | // Normal shocker actions 386 | case "": 387 | break; 388 | // Ignore all other actions 389 | default: 390 | return; 391 | } 392 | 393 | if (value is true) 394 | { 395 | programGroup.TriggerMethod = TriggerMethod.Manual; 396 | programGroup.LastActive = DateTime.UtcNow; 397 | } 398 | else programGroup.TriggerMethod = TriggerMethod.None; 399 | } 400 | 401 | private async Task PhysboneHandling(ProgramGroup programGroup, bool isGrabbed) 402 | { 403 | switch (programGroup.IsGrabbed) 404 | { 405 | // Physbone was grabbed, and is now released 406 | case true when !isGrabbed: 407 | { 408 | programGroup.TriggerMethod = TriggerMethod.None; 409 | 410 | // When the stretch value is not 0, we send the action 411 | if (programGroup.LastStretchValue != 0) 412 | { 413 | 414 | // Check all preconditions, maybe send stop command here aswell? 415 | if (!await HandlePrecondition(CheckAndSetAllPreconditions(programGroup), programGroup)) return; 416 | 417 | var releaseAction = _moduleConfig.Config.GetGroupOrGlobal(programGroup, 418 | behaviourConfig => behaviourConfig.WhenBoneReleased, 419 | group => group.OverrideBoneReleasedAction); 420 | 421 | if (releaseAction == BoneAction.None) 422 | { 423 | programGroup.LastStretchValue = 0; 424 | return; 425 | } 426 | 427 | var physBoneIntensity = GetPhysbonePullIntensity(programGroup, programGroup.LastStretchValue); 428 | programGroup.LastStretchValue = 0; 429 | 430 | SendCommand(programGroup, GetDuration(programGroup), physBoneIntensity, releaseAction.ToControlType(), 431 | true); 432 | 433 | return; 434 | } 435 | 436 | // If the stretch value is 0, we stop the group 437 | if (_moduleConfig.Config.GetGroupOrGlobal(programGroup, config => config.WhileBoneHeld, 438 | group => group.OverrideBoneHeldAction) != BoneAction.None) 439 | { 440 | _logger.LogTrace("Physbone released, stopping group {Group}", programGroup.Name); 441 | await ControlGroup(programGroup.Id, 0, 0, ControlType.Stop); 442 | } 443 | 444 | break; 445 | } 446 | // Physbone is being grabbed now but was not grabbed before 447 | case false when isGrabbed: 448 | { 449 | // on physbone grab 450 | var durationLimit = _moduleConfig.Config.GetGroupOrGlobal(programGroup, 451 | config => config.BoneHeldDurationLimit, group => group.OverrideBoneHeldDurationLimit); 452 | programGroup.PhysBoneGrabLimitTime = durationLimit == null 453 | ? null 454 | : DateTime.UtcNow.AddMilliseconds(durationLimit.Value); 455 | _logger.LogDebug("Limiting hold duration of Group {Group} to {Duration}ms", programGroup.Name, 456 | durationLimit); 457 | break; 458 | } 459 | } 460 | } 461 | 462 | private async ValueTask HandlePrecondition(OneOf.OneOf result, ProgramGroup programGroup) 463 | { 464 | await result.Match( 465 | success => ValueTask.CompletedTask, 466 | killSwitch => LogIgnoredKillSwitchActive(), 467 | cooldown => ValueTask.CompletedTask, 468 | paused => LogIgnoredGroupKillSwitchActive(programGroup), 469 | afk => LogIgnoredAfk()); 470 | 471 | return result.IsT0; 472 | } 473 | 474 | private ValueTask LogIgnoredKillSwitchActive() 475 | { 476 | _logger.LogInformation("Ignoring shock, kill switch is active"); 477 | if (string.IsNullOrEmpty(_moduleConfig.Config.Chatbox.IgnoredKillSwitchActive)) 478 | return ValueTask.CompletedTask; 479 | 480 | return _chatboxService.SendGenericMessage(_moduleConfig.Config.Chatbox.IgnoredKillSwitchActive); 481 | } 482 | 483 | private ValueTask LogIgnoredGroupKillSwitchActive(ProgramGroup programGroup) 484 | { 485 | _logger.LogInformation($"Ignoring shock, kill switch of {programGroup.Name} is active"); 486 | if (string.IsNullOrEmpty(_moduleConfig.Config.Chatbox.IgnoredGroupPauseActive)) 487 | return ValueTask.CompletedTask; 488 | 489 | return _chatboxService.SendGroupPausedMessage(programGroup); 490 | } 491 | 492 | private ValueTask LogIgnoredAfk() 493 | { 494 | _logger.LogInformation("Ignoring shock, user is AFK"); 495 | if (string.IsNullOrEmpty(_moduleConfig.Config.Chatbox.IgnoredAfk)) 496 | return ValueTask.CompletedTask; 497 | 498 | return _chatboxService.SendGenericMessage(_moduleConfig.Config.Chatbox.IgnoredAfk); 499 | } 500 | 501 | private async Task SenderLoopAsync() 502 | { 503 | while (_oscServerActive) 504 | { 505 | await _oscHandler.SendParams(); 506 | await Task.Delay(300); 507 | } 508 | } 509 | 510 | private async Task ControlGroup(Guid groupId, ushort duration, byte intensity, ControlType type, 511 | bool exclusive = false) 512 | { 513 | if (groupId == Guid.Empty) 514 | { 515 | var controlCommandsAll = _openShockService.Data.Hubs.Value.SelectMany(x => x.Shockers) 516 | .Select(x => new ShockerControl 517 | { 518 | Id = x.Id, 519 | Duration = duration, 520 | Intensity = intensity, 521 | Type = type, 522 | Exclusive = exclusive 523 | }); 524 | await _openShockService.Control.Control(controlCommandsAll); 525 | return true; 526 | } 527 | 528 | if (!_moduleConfig.Config.Groups.TryGetValue(groupId, out var group)) return false; 529 | 530 | var controlCommands = group.Shockers.Select(x => new ShockerControl 531 | { 532 | Id = x, 533 | Duration = duration, 534 | Intensity = intensity, 535 | Type = type, 536 | Exclusive = exclusive 537 | }); 538 | 539 | await _openShockService.Control.Control(controlCommands); 540 | return true; 541 | } 542 | 543 | private async Task SendCommand(ProgramGroup programGroup, ushort duration, byte intensity, ControlType type, 544 | bool exclusive = false) 545 | { 546 | // Intensity is pre scaled to 0 - 100 547 | var actualIntensity = programGroup.NextIntensity == 0 ? intensity : programGroup.NextIntensity; 548 | var actualDuration = programGroup.NextDuration == 0 549 | ? duration 550 | : GetScaledDuration(programGroup, programGroup.NextDuration); 551 | 552 | if (type == ControlType.Shock) 553 | { 554 | programGroup.LastExecuted = DateTime.UtcNow; 555 | programGroup.LastDuration = actualDuration; 556 | programGroup.LastIntensity = actualIntensity; 557 | _oscHandler.ForceUnmute(); 558 | _oscHandler.SendParams(); 559 | } 560 | 561 | programGroup.TriggerMethod = TriggerMethod.None; 562 | var inSeconds = MathF.Round(actualDuration / 1000f, 1).ToString(CultureInfo.InvariantCulture); 563 | 564 | if (_moduleConfig.Config.MedalIcymi.Enabled) 565 | { 566 | await _medalIcymiService.TriggerMedalIcymiAction("evt_shockosc_triggered"); 567 | } 568 | 569 | _logger.LogInformation( 570 | "Sending {Type} to {GroupName} Intensity: {Intensity} Length:{Length}s Exclusive: {Exclusive}", type, 571 | programGroup.Name, actualIntensity, inSeconds, exclusive); 572 | 573 | 574 | await ControlGroup(programGroup.Id, actualDuration, actualIntensity, type, exclusive); 575 | await _chatboxService.SendLocalControlMessage(programGroup.Name, actualIntensity, actualDuration, type); 576 | } 577 | 578 | private async Task CheckLoop() 579 | { 580 | while (_oscServerActive) 581 | { 582 | try 583 | { 584 | await CheckLogic(); 585 | } 586 | catch (Exception e) 587 | { 588 | _logger.LogError(e, "Error in check loop"); 589 | } 590 | 591 | await Task.Delay(20); 592 | } 593 | } 594 | 595 | private async Task CheckLogic() 596 | { 597 | foreach (var (pos, programGroup) in _dataLayer.ProgramGroups) 598 | { 599 | await CheckProgramGroup(programGroup, pos); 600 | } 601 | } 602 | 603 | private void LiveControlGroupFrameCheckLoop(ProgramGroup group, byte intensity, ControlType type) 604 | { 605 | if (group.Id == Guid.Empty) 606 | { 607 | _openShockService.Control.ControlAllShockers(intensity, type); 608 | return; 609 | } 610 | 611 | if (group.ConfigGroup == null) 612 | { 613 | _logger.LogWarning("Group [{GroupId}] does not have a config group", group.Id); 614 | return; 615 | } 616 | 617 | _openShockService.Control.LiveControl(group.ConfigGroup.Shockers, intensity, type); 618 | } 619 | 620 | private async Task CheckProgramGroup(ProgramGroup programGroup, Guid pos) 621 | { 622 | var pass = CheckAndSetAllPreconditions(programGroup); 623 | 624 | # region Concurrent Handling 625 | 626 | if (programGroup.ConcurrentIntensity != 0 && pass.IsT0) 627 | { 628 | LiveControlGroupFrameCheckLoop(programGroup, 629 | GetScaledIntensity(programGroup, programGroup.ConcurrentIntensity), programGroup.ConcurrentType); 630 | programGroup.LastConcurrentIntensity = programGroup.ConcurrentIntensity; 631 | return; 632 | } 633 | 634 | // This means concurrent intensity is 0 635 | if (programGroup.LastConcurrentIntensity != 0) 636 | { 637 | LiveControlGroupFrameCheckLoop(programGroup, 0, ControlType.Stop); 638 | programGroup.LastConcurrentIntensity = 0; 639 | } 640 | 641 | # endregion 642 | 643 | // Physbone while held handling 644 | if (programGroup.TriggerMethod == TriggerMethod.None && programGroup.IsGrabbed) 645 | { 646 | if(!await HandlePrecondition(pass, programGroup)) return; 647 | 648 | var heldAction = _moduleConfig.Config.GetGroupOrGlobal(programGroup, 649 | behaviourConfig => behaviourConfig.WhileBoneHeld, 650 | group => group.OverrideBoneHeldAction); 651 | 652 | if (heldAction != BoneAction.None && (programGroup.PhysBoneGrabLimitTime == null || 653 | programGroup.PhysBoneGrabLimitTime > DateTime.UtcNow) && 654 | programGroup.LastHeldAction < DateTime.UtcNow.Subtract(TimeSpan.FromMilliseconds(100))) 655 | { 656 | var pullIntensityTranslated = GetPhysbonePullIntensity(programGroup, programGroup.LastStretchValue); 657 | programGroup.LastHeldAction = DateTime.UtcNow; 658 | 659 | _logger.LogDebug("Vibrating/Shocking {Shocker} at {Intensity}", pos, pullIntensityTranslated); 660 | 661 | LiveControlGroupFrameCheckLoop(programGroup, pullIntensityTranslated, 662 | heldAction.ToControlType()); 663 | } 664 | } 665 | 666 | // Regular touch trigger 667 | 668 | if (programGroup.TriggerMethod == TriggerMethod.None) 669 | return; 670 | 671 | if (programGroup.TriggerMethod == TriggerMethod.Manual && 672 | programGroup.LastActive.AddMilliseconds(_moduleConfig.Config.Behaviour.HoldTime) > DateTime.UtcNow) 673 | return; 674 | 675 | if(!await HandlePrecondition(pass, programGroup)) return; 676 | 677 | 678 | SendCommand(programGroup, GetDuration(programGroup), GetIntensity(programGroup), ControlType.Shock, false); 679 | } 680 | 681 | private OneOf.OneOf CheckAndSetAllPreconditions(ProgramGroup programGroup) 682 | { 683 | var configBehaviour = _moduleConfig.Config.Behaviour; 684 | 685 | if (_underscoreConfig.KillSwitch) 686 | { 687 | programGroup.TriggerMethod = TriggerMethod.None; 688 | return new KillSwitch(); 689 | } 690 | 691 | if (programGroup.Paused) 692 | { 693 | programGroup.TriggerMethod = TriggerMethod.None; 694 | return new Paused(); 695 | } 696 | 697 | if (_isAfk && configBehaviour.DisableWhileAfk) 698 | { 699 | programGroup.TriggerMethod = TriggerMethod.None; 700 | return new Afk(); 701 | } 702 | 703 | var cooldownTime = configBehaviour.CooldownTime; 704 | if (programGroup.ConfigGroup is { OverrideCooldownTime: true }) 705 | cooldownTime = programGroup.ConfigGroup.CooldownTime; 706 | 707 | var isActiveOrOnCooldown = 708 | programGroup.LastExecuted.AddMilliseconds(cooldownTime) 709 | .AddMilliseconds(programGroup.LastDuration) > DateTime.UtcNow; 710 | 711 | if (isActiveOrOnCooldown) 712 | { 713 | programGroup.TriggerMethod = TriggerMethod.None; 714 | return new Cooldown(); 715 | } 716 | 717 | return new Success(); 718 | } 719 | 720 | private ushort GetScaledDuration(ProgramGroup programGroup, float scale) 721 | { 722 | scale = MathUtils.Saturate(scale); 723 | 724 | if (programGroup.ConfigGroup is not { OverrideDuration: true }) 725 | { 726 | // Use global config 727 | var config = _moduleConfig.Config.Behaviour; 728 | 729 | if (!config.RandomDuration) return (ushort)(config.FixedDuration * scale); 730 | var rdr = config.DurationRange; 731 | return (ushort) 732 | (MathUtils.LerpUShort( 733 | (ushort)(rdr.Min / DurationStep), (ushort)(rdr.Max / DurationStep), scale) 734 | * DurationStep); 735 | } 736 | 737 | // Use group config 738 | var groupConfig = programGroup.ConfigGroup; 739 | 740 | if (!groupConfig.RandomDuration) return (ushort)(groupConfig.FixedDuration * scale); 741 | var groupRdr = groupConfig.DurationRange; 742 | return (ushort)(MathUtils.LerpUShort((ushort)(groupRdr.Min / DurationStep), 743 | (ushort)(groupRdr.Max / DurationStep), scale) * DurationStep); 744 | } 745 | 746 | private byte GetScaledIntensity(ProgramGroup programGroup, byte intensity) 747 | { 748 | if (programGroup.ConfigGroup is not { OverrideIntensity: true }) 749 | { 750 | // Use global config 751 | var config = _moduleConfig.Config.Behaviour; 752 | 753 | if (!config.RandomIntensity) return (byte)MathUtils.LerpFloat(0, config.FixedIntensity, intensity / 100f); 754 | return (byte)MathUtils.LerpFloat(config.IntensityRange.Min, config.IntensityRange.Max, intensity / 100f); 755 | } 756 | 757 | // Use group config 758 | var groupConfig = programGroup.ConfigGroup; 759 | 760 | if (!groupConfig.RandomIntensity) 761 | return (byte)MathUtils.LerpFloat(0, groupConfig.FixedIntensity, intensity / 100f); 762 | return (byte)MathUtils.LerpFloat(groupConfig.IntensityRange.Min, groupConfig.IntensityRange.Max, 763 | intensity / 100f); 764 | } 765 | 766 | private byte GetPhysbonePullIntensity(ProgramGroup programGroup, float stretch) 767 | { 768 | stretch = MathUtils.Saturate(stretch); 769 | if (programGroup.ConfigGroup is not { OverrideIntensity: true }) 770 | { 771 | // Use global config 772 | var config = _moduleConfig.Config.Behaviour; 773 | 774 | if (!config.RandomIntensity) return config.FixedIntensity; 775 | return (byte)MathUtils.LerpFloat(config.IntensityRange.Min, config.IntensityRange.Max, stretch); 776 | } 777 | 778 | // Use group config 779 | var groupConfig = programGroup.ConfigGroup; 780 | 781 | if (!groupConfig.RandomIntensity) return groupConfig.FixedIntensity; 782 | return (byte)MathUtils.LerpFloat(groupConfig.IntensityRange.Min, groupConfig.IntensityRange.Max, stretch); 783 | } 784 | 785 | private const ushort DurationStep = 100; 786 | 787 | private ushort GetDuration(ProgramGroup programGroup) 788 | { 789 | if (programGroup.ConfigGroup is not { OverrideDuration: true }) 790 | { 791 | // Use global config 792 | var config = _moduleConfig.Config.Behaviour; 793 | 794 | if (!config.RandomDuration) return config.FixedDuration; 795 | var rdr = config.DurationRange; 796 | return (ushort)(Random.Next(rdr.Min / DurationStep, 797 | rdr.Max / DurationStep) * DurationStep); 798 | } 799 | 800 | // Use group config 801 | var groupConfig = programGroup.ConfigGroup; 802 | 803 | if (!groupConfig.RandomDuration) return groupConfig.FixedDuration; 804 | var groupRdr = groupConfig.DurationRange; 805 | return (ushort)(Random.Next(groupRdr.Min / DurationStep, 806 | groupRdr.Max / DurationStep) * DurationStep); 807 | } 808 | 809 | private byte GetIntensity(ProgramGroup programGroup) 810 | { 811 | if (programGroup.ConfigGroup is not { OverrideIntensity: true }) 812 | { 813 | // Use global config 814 | var config = _moduleConfig.Config.Behaviour; 815 | 816 | if (!config.RandomIntensity) return config.FixedIntensity; 817 | var rir = config.IntensityRange; 818 | var intensityValue = Random.Next(rir.Min, rir.Max); 819 | return (byte)intensityValue; 820 | } 821 | 822 | // Use groupConfig 823 | var groupConfig = programGroup.ConfigGroup; 824 | 825 | if (!groupConfig.RandomIntensity) return groupConfig.FixedIntensity; 826 | var groupRir = groupConfig.IntensityRange; 827 | var groupIntensityValue = Random.Next(groupRir.Min, groupRir.Max); 828 | return (byte)groupIntensityValue; 829 | } 830 | } 831 | 832 | public struct KillSwitch; 833 | public struct Cooldown; 834 | public struct Paused; 835 | public struct Afk; -------------------------------------------------------------------------------- /ShockOsc/Services/ShockOscData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using OpenShock.ShockOSC.Models; 3 | 4 | namespace OpenShock.ShockOSC.Services; 5 | 6 | // In a perfect world, this class would not exist. 7 | // But we kinda need it for now, dunno if it is possible to be removed ever. 8 | public sealed class ShockOscData 9 | { 10 | public ConcurrentDictionary ProgramGroups { get; } = new(); 11 | 12 | public bool IsMuted { get; set; } 13 | } -------------------------------------------------------------------------------- /ShockOsc/Services/UnderscoreConfig.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using OpenShock.Desktop.ModuleBase.Config; 3 | using OpenShock.ShockOSC.Config; 4 | using OpenShock.ShockOSC.Models; 5 | using OpenShock.ShockOSC.Utils; 6 | 7 | namespace OpenShock.ShockOSC.Services; 8 | 9 | public sealed class UnderscoreConfig 10 | { 11 | private readonly ILogger _logger; 12 | private readonly OscClient _oscClient; 13 | private readonly IModuleConfig _moduleConfig; 14 | private readonly ShockOscData _dataLayer; 15 | 16 | public event Action? OnConfigUpdate; 17 | public event Action? OnGroupConfigUpdate; 18 | 19 | public UnderscoreConfig(ILogger logger, OscClient oscClient, IModuleConfig moduleConfig, 20 | ShockOscData dataLayer) 21 | { 22 | _logger = logger; 23 | _oscClient = oscClient; 24 | _moduleConfig = moduleConfig; 25 | _dataLayer = dataLayer; 26 | } 27 | 28 | public bool KillSwitch { get; set; } = false; 29 | 30 | public bool GetProgramGroupFromGUID(Guid guid, out ProgramGroup? group) 31 | { 32 | return _dataLayer.ProgramGroups.TryGetValue(guid, out group); 33 | } 34 | 35 | public void HandleCommand(string parameterName, object?[] arguments) 36 | { 37 | var settingName = parameterName[8..]; 38 | 39 | var settingPath = settingName.Split('/'); 40 | if (settingPath.Length is > 2 or <= 0) 41 | { 42 | _logger.LogWarning("Invalid setting path: {SettingName}", settingName); 43 | return; 44 | } 45 | 46 | var value = arguments.ElementAtOrDefault(0); 47 | 48 | #region Legacy 49 | 50 | // Legacy Paused setting 51 | if (settingPath.Length == 1) 52 | { 53 | if (settingName != "Paused" || value is not bool stateBool || KillSwitch == stateBool) return; 54 | 55 | KillSwitch = stateBool; 56 | OnConfigUpdate?.Invoke(); // update Ui 57 | _logger.LogInformation("Paused state set to: {KillSwitch}", KillSwitch); 58 | return; 59 | } 60 | 61 | #endregion 62 | 63 | var groupName = settingPath[0]; 64 | var action = settingPath[1]; 65 | if (!_dataLayer.ProgramGroups.Any(x => 66 | x.Value.Name.Equals(groupName, StringComparison.InvariantCultureIgnoreCase)) && groupName != "_All") 67 | { 68 | _logger.LogWarning("Unknown shocker {Shocker}", groupName); 69 | _logger.LogDebug("Param: {Param}", action); 70 | return; 71 | } 72 | 73 | // Handle global config commands 74 | if (groupName == "_All") 75 | { 76 | HandleGlobalConfigCommand(action, value); 77 | return; 78 | } 79 | 80 | var group = _dataLayer.ProgramGroups.First(x => 81 | x.Value.Name.Equals(groupName, StringComparison.InvariantCultureIgnoreCase)); 82 | 83 | HandleGroupConfigCommand(group.Value, action, value); 84 | } 85 | 86 | private void HandleGlobalConfigCommand(string action, object? value) 87 | { 88 | switch (action) 89 | { 90 | case "ModeIntensity": 91 | if (value is bool modeIntensity) 92 | { 93 | if (_moduleConfig.Config.Behaviour.RandomIntensity == modeIntensity) return; 94 | _moduleConfig.Config.Behaviour.RandomIntensity = modeIntensity; 95 | _moduleConfig.SaveDeferred(); 96 | OnConfigUpdate?.Invoke(); // update Ui 97 | } 98 | break; 99 | 100 | case "ModeDuration": 101 | if (value is bool modeDuration) 102 | { 103 | if(_moduleConfig.Config.Behaviour.RandomDuration == modeDuration) return; 104 | _moduleConfig.Config.Behaviour.RandomDuration = modeDuration; 105 | _moduleConfig.SaveDeferred(); 106 | OnConfigUpdate?.Invoke(); // update Ui 107 | } 108 | break; 109 | 110 | case "Intensity": 111 | // 0..10sec 112 | if (value is float intensityFloat) 113 | { 114 | var currentIntensity = 115 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.FixedIntensity / 100f); 116 | if (Math.Abs(intensityFloat - currentIntensity) < 0.001) return; 117 | 118 | _moduleConfig.Config.Behaviour.FixedIntensity = 119 | Math.Clamp((byte)Math.Round(intensityFloat * 100), (byte)0, (byte)100); 120 | _moduleConfig.Config.Behaviour.RandomIntensity = false; 121 | _moduleConfig.SaveDeferred(); 122 | OnConfigUpdate?.Invoke(); // update Ui 123 | } 124 | 125 | break; 126 | 127 | case "MinIntensity": 128 | // 0..100% 129 | if (value is float minIntensityFloat) 130 | { 131 | var currentMinIntensity = 132 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.IntensityRange.Min / 100f); 133 | if (Math.Abs(minIntensityFloat - currentMinIntensity) < 0.001) return; 134 | 135 | _moduleConfig.Config.Behaviour.IntensityRange.Min = 136 | MathUtils.ClampByte((byte)Math.Round(minIntensityFloat * 100), 0, 100); 137 | _moduleConfig.Config.Behaviour.RandomIntensity = true; 138 | if (_moduleConfig.Config.Behaviour.IntensityRange.Max < _moduleConfig.Config.Behaviour.IntensityRange.Min) 139 | _moduleConfig.Config.Behaviour.IntensityRange.Max = _moduleConfig.Config.Behaviour.IntensityRange.Min; 140 | 141 | _moduleConfig.SaveDeferred(); 142 | OnConfigUpdate?.Invoke(); // update Ui 143 | } 144 | 145 | break; 146 | 147 | case "MaxIntensity": 148 | // 0..100% 149 | if (value is float maxIntensityFloat) 150 | { 151 | var currentMaxIntensity = 152 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.IntensityRange.Max / 100f); 153 | if (Math.Abs(maxIntensityFloat - currentMaxIntensity) < 0.001) return; 154 | 155 | _moduleConfig.Config.Behaviour.IntensityRange.Max = 156 | MathUtils.ClampByte((byte)Math.Round(maxIntensityFloat * 100), 0, 100); 157 | _moduleConfig.Config.Behaviour.RandomIntensity = true; 158 | if (_moduleConfig.Config.Behaviour.IntensityRange.Max < _moduleConfig.Config.Behaviour.IntensityRange.Min) 159 | _moduleConfig.Config.Behaviour.IntensityRange.Min = _moduleConfig.Config.Behaviour.IntensityRange.Max; 160 | 161 | _moduleConfig.SaveDeferred(); 162 | OnConfigUpdate?.Invoke(); // update Ui 163 | } 164 | 165 | break; 166 | 167 | case "MinDuration": 168 | // 0..10sec 169 | if (value is float minDurationFloat) 170 | { 171 | var currentMinDuration = _moduleConfig.Config.Behaviour.DurationRange.Min / 10_000f; 172 | if (Math.Abs(minDurationFloat - currentMinDuration) < 0.001) return; 173 | 174 | _moduleConfig.Config.Behaviour.DurationRange.Min = 175 | MathUtils.ClampUShort((ushort)Math.Round(minDurationFloat * 10_000), 300, 30_000); 176 | _moduleConfig.Config.Behaviour.RandomDuration = true; 177 | if (_moduleConfig.Config.Behaviour.DurationRange.Max < _moduleConfig.Config.Behaviour.DurationRange.Min) 178 | _moduleConfig.Config.Behaviour.DurationRange.Max = _moduleConfig.Config.Behaviour.DurationRange.Min; 179 | 180 | _moduleConfig.SaveDeferred(); 181 | OnConfigUpdate?.Invoke(); // update Ui 182 | } 183 | 184 | break; 185 | 186 | case "MaxDuration": 187 | // 0..10sec 188 | if (value is float maxDurationFloat) 189 | { 190 | var currentMaxDuration = _moduleConfig.Config.Behaviour.DurationRange.Max / 10_000f; 191 | if (Math.Abs(maxDurationFloat - currentMaxDuration) < 0.001) return; 192 | 193 | _moduleConfig.Config.Behaviour.DurationRange.Max = 194 | MathUtils.ClampUShort((ushort)Math.Round(maxDurationFloat * 10_000), 300, 30_000); 195 | _moduleConfig.Config.Behaviour.RandomDuration = true; 196 | if (_moduleConfig.Config.Behaviour.DurationRange.Max < _moduleConfig.Config.Behaviour.DurationRange.Min) 197 | _moduleConfig.Config.Behaviour.DurationRange.Min = _moduleConfig.Config.Behaviour.DurationRange.Max; 198 | 199 | _moduleConfig.SaveDeferred(); 200 | OnConfigUpdate?.Invoke(); // update Ui 201 | } 202 | 203 | break; 204 | 205 | case "Duration": 206 | // 0..10sec 207 | if (value is float durationFloat) 208 | { 209 | var currentDuration = _moduleConfig.Config.Behaviour.FixedDuration / 10000f; 210 | if (Math.Abs(durationFloat - currentDuration) < 0.001) return; 211 | 212 | _moduleConfig.Config.Behaviour.FixedDuration = 213 | MathUtils.ClampUShort((ushort)Math.Round(durationFloat * 10_000), 300, 10_000); 214 | _moduleConfig.Config.Behaviour.RandomDuration = false; 215 | _moduleConfig.SaveDeferred(); 216 | OnConfigUpdate?.Invoke(); // update Ui 217 | } 218 | 219 | break; 220 | 221 | case "CooldownTime": 222 | // 0..100sec 223 | if (value is float cooldownTimeFloat) 224 | { 225 | var currentCooldownTime = 226 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.CooldownTime / 100000f); 227 | if (Math.Abs(cooldownTimeFloat - currentCooldownTime) < 0.001) return; 228 | 229 | _moduleConfig.Config.Behaviour.CooldownTime = 230 | MathUtils.ClampUint((uint)Math.Round(cooldownTimeFloat * 100000), 0, 100000); 231 | _moduleConfig.SaveDeferred(); 232 | OnConfigUpdate?.Invoke(); // update Ui 233 | } 234 | 235 | break; 236 | 237 | case "HoldTime": 238 | // 0..1sec 239 | if (value is float holdTimeFloat) 240 | { 241 | var currentHoldTime = MathUtils.Saturate(_moduleConfig.Config.Behaviour.HoldTime / 1000f); 242 | if (Math.Abs(holdTimeFloat - currentHoldTime) < 0.001) return; 243 | 244 | _moduleConfig.Config.Behaviour.HoldTime = 245 | MathUtils.ClampUint((uint)Math.Round(holdTimeFloat * 1000), 0, 1000); 246 | _moduleConfig.SaveDeferred(); 247 | OnConfigUpdate?.Invoke(); // update Ui 248 | } 249 | 250 | break; 251 | 252 | case "Paused": 253 | if (value is bool stateBool) 254 | { 255 | if (KillSwitch == stateBool) return; 256 | 257 | KillSwitch = stateBool; 258 | OnConfigUpdate?.Invoke(); // update Ui 259 | _logger.LogInformation("Paused state set to: {KillSwitch}", KillSwitch); 260 | } 261 | 262 | break; 263 | } 264 | } 265 | 266 | private void HandleGroupConfigCommand(ProgramGroup group, string action, object? value) 267 | { 268 | //dont know if this is needed since all normal groups have a ConfigGroup, if it doesnt have it you are fucked anyway 269 | if (group.ConfigGroup == null) throw new ArgumentException("ConfigGroup is Null"); 270 | 271 | switch (action) 272 | { 273 | case "ModeIntensity": 274 | if (value is bool modeIntensity) 275 | { 276 | if (group.ConfigGroup.RandomIntensity == modeIntensity) return; 277 | group.ConfigGroup.RandomIntensity = modeIntensity; 278 | group.ConfigGroup.OverrideIntensity = true; 279 | 280 | _moduleConfig.SaveDeferred(); 281 | OnGroupConfigUpdate?.Invoke(); // update Ui 282 | } 283 | break; 284 | 285 | case "ModeDuration": 286 | if (value is bool modeDuration) 287 | { 288 | if (group.ConfigGroup.RandomDuration == modeDuration) return; 289 | group.ConfigGroup.RandomDuration = modeDuration; 290 | group.ConfigGroup.OverrideDuration = true; 291 | 292 | _moduleConfig.SaveDeferred(); 293 | OnGroupConfigUpdate?.Invoke(); // update Ui 294 | } 295 | break; 296 | 297 | case "Intensity": 298 | // 0..10sec 299 | if (value is float intensityFloat) 300 | { 301 | var currentIntensity = 302 | MathUtils.Saturate(group.ConfigGroup.FixedIntensity / 100f); 303 | if (Math.Abs(intensityFloat - currentIntensity) < 0.001) return; 304 | 305 | group.ConfigGroup.FixedIntensity = 306 | Math.Clamp((byte)Math.Round(intensityFloat * 100), (byte)0, (byte)100); 307 | group.ConfigGroup.RandomIntensity = false; 308 | group.ConfigGroup.OverrideIntensity = true; 309 | 310 | _moduleConfig.SaveDeferred(); 311 | OnGroupConfigUpdate?.Invoke(); // update Ui 312 | } 313 | 314 | break; 315 | 316 | case "MinIntensity": 317 | // 0..100% 318 | if (value is float minIntensityFloat) 319 | { 320 | var currentMinIntensity = 321 | MathUtils.Saturate(group.ConfigGroup.IntensityRange.Min / 100f); 322 | if (Math.Abs(minIntensityFloat - currentMinIntensity) < 0.001) return; 323 | 324 | group.ConfigGroup.IntensityRange.Min = 325 | MathUtils.ClampByte((byte)Math.Round(minIntensityFloat * 100), 0, 100); 326 | if (group.ConfigGroup.IntensityRange.Max < group.ConfigGroup.IntensityRange.Min) 327 | group.ConfigGroup.IntensityRange.Max = group.ConfigGroup.IntensityRange.Min; 328 | 329 | group.ConfigGroup.RandomIntensity = true; 330 | group.ConfigGroup.OverrideIntensity = true; 331 | 332 | _moduleConfig.SaveDeferred(); 333 | OnGroupConfigUpdate?.Invoke(); // update Ui 334 | } 335 | 336 | break; 337 | 338 | case "MaxIntensity": 339 | // 0..100% 340 | if (value is float maxIntensityFloat) 341 | { 342 | var currentMaxIntensity = 343 | MathUtils.Saturate(group.ConfigGroup.IntensityRange.Max / 100f); 344 | if (Math.Abs(maxIntensityFloat - currentMaxIntensity) < 0.001) return; 345 | 346 | group.ConfigGroup.IntensityRange.Max = 347 | MathUtils.ClampByte((byte)Math.Round(maxIntensityFloat * 100), 0, 100); 348 | if (group.ConfigGroup.IntensityRange.Max < group.ConfigGroup.IntensityRange.Min) 349 | group.ConfigGroup.IntensityRange.Min = group.ConfigGroup.IntensityRange.Max; 350 | 351 | group.ConfigGroup.RandomIntensity = true; 352 | group.ConfigGroup.OverrideIntensity = true; 353 | 354 | _moduleConfig.SaveDeferred(); 355 | OnGroupConfigUpdate?.Invoke(); // update Ui 356 | } 357 | 358 | break; 359 | 360 | case "MinDuration": 361 | // 0..10sec 362 | if (value is float minDurationFloat) 363 | { 364 | var currentMinDuration = group.ConfigGroup.DurationRange.Min / 10_000f; 365 | if (Math.Abs(minDurationFloat - currentMinDuration) < 0.001) return; 366 | 367 | group.ConfigGroup.DurationRange.Min = 368 | MathUtils.ClampUShort((ushort)Math.Round(minDurationFloat * 10_000), 300, 30_000); 369 | if (group.ConfigGroup.DurationRange.Max < group.ConfigGroup.DurationRange.Min) 370 | group.ConfigGroup.DurationRange.Max = group.ConfigGroup.DurationRange.Min; 371 | 372 | group.ConfigGroup.RandomDuration = true; 373 | group.ConfigGroup.OverrideDuration = true; 374 | 375 | _moduleConfig.SaveDeferred(); 376 | OnGroupConfigUpdate?.Invoke(); // update Ui 377 | } 378 | 379 | break; 380 | 381 | case "MaxDuration": 382 | // 0..10sec 383 | if (value is float maxDurationFloat) 384 | { 385 | var currentMaxDuration = group.ConfigGroup.DurationRange.Max / 10_000f; 386 | if (Math.Abs(maxDurationFloat - currentMaxDuration) < 0.001) return; 387 | 388 | group.ConfigGroup.DurationRange.Max = 389 | MathUtils.ClampUShort((ushort)Math.Round(maxDurationFloat * 10_000), 300, 30_000); 390 | if(group.ConfigGroup.DurationRange.Max < group.ConfigGroup.DurationRange.Min) 391 | group.ConfigGroup.DurationRange.Min = group.ConfigGroup.DurationRange.Max; 392 | 393 | group.ConfigGroup.RandomDuration = true; 394 | group.ConfigGroup.OverrideDuration = true; 395 | 396 | _moduleConfig.SaveDeferred(); 397 | OnGroupConfigUpdate?.Invoke(); // update Ui 398 | } 399 | 400 | break; 401 | 402 | case "Duration": 403 | // 0..10sec 404 | if (value is float durationFloat) 405 | { 406 | var currentDuration = group.ConfigGroup.FixedDuration / 10000f; 407 | if (Math.Abs(durationFloat - currentDuration) < 0.001) return; 408 | 409 | group.ConfigGroup.FixedDuration = 410 | MathUtils.ClampUShort((ushort)Math.Round(durationFloat * 10_000), 300, 10_000); 411 | group.ConfigGroup.RandomDuration = false; 412 | group.ConfigGroup.OverrideDuration = true; 413 | 414 | _moduleConfig.SaveDeferred(); 415 | OnGroupConfigUpdate?.Invoke(); // update Ui 416 | } 417 | 418 | break; 419 | 420 | case "CooldownTime": 421 | // 0..100sec 422 | if (value is float cooldownTimeFloat) 423 | { 424 | var currentCooldownTime = 425 | MathUtils.Saturate(group.ConfigGroup.CooldownTime / 100000f); 426 | if (Math.Abs(cooldownTimeFloat - currentCooldownTime) < 0.001) return; 427 | 428 | group.ConfigGroup.CooldownTime = 429 | MathUtils.ClampUint((uint)Math.Round(cooldownTimeFloat * 100000), 0, 100000); 430 | group.ConfigGroup.OverrideCooldownTime = true; 431 | 432 | _moduleConfig.SaveDeferred(); 433 | OnGroupConfigUpdate?.Invoke(); // update Ui 434 | } 435 | 436 | break; 437 | 438 | case "OverrideCooldownTime": 439 | if (value is bool overrideCooldownTime) 440 | { 441 | if (overrideCooldownTime == group.ConfigGroup.OverrideCooldownTime) return; 442 | group.ConfigGroup.OverrideCooldownTime = overrideCooldownTime; 443 | _moduleConfig.SaveDeferred(); 444 | OnGroupConfigUpdate?.Invoke(); // update Ui 445 | } 446 | 447 | break; 448 | 449 | case "OverrideIntensity": 450 | if (value is bool overrideIntensity) 451 | { 452 | if (overrideIntensity == group.ConfigGroup.OverrideIntensity) return; 453 | group.ConfigGroup.OverrideIntensity = overrideIntensity; 454 | _moduleConfig.SaveDeferred(); 455 | OnGroupConfigUpdate?.Invoke(); // update Ui 456 | } 457 | 458 | break; 459 | 460 | case "OverrideDuration": 461 | if (value is bool overrideDuration) 462 | { 463 | if (overrideDuration == group.ConfigGroup.OverrideDuration) return; 464 | group.ConfigGroup.OverrideDuration = overrideDuration; 465 | _moduleConfig.SaveDeferred(); 466 | OnGroupConfigUpdate?.Invoke(); // update Ui 467 | } 468 | 469 | break; 470 | 471 | case "Paused": 472 | if (value is bool stateBool) 473 | { 474 | if (group.Paused == stateBool) return; 475 | 476 | group.Paused = stateBool; 477 | OnGroupConfigUpdate?.Invoke(); // update Ui 478 | _logger.LogInformation($"Paused state for {group.Name} set to: {group.Paused}"); 479 | } 480 | 481 | break; 482 | } 483 | } 484 | 485 | public async Task SendUpdateForAll() 486 | { 487 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/Paused", KillSwitch); 488 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/Paused", KillSwitch); 489 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/MinIntensity", 490 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.IntensityRange.Min / 100f)); 491 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/MaxIntensity", 492 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.IntensityRange.Max / 100f)); 493 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/Duration", 494 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.FixedDuration / 10000f)); 495 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/CooldownTime", 496 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.CooldownTime / 100000f)); 497 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/HoldTime", 498 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.HoldTime / 1000f)); 499 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/ModeIntensity", 500 | _moduleConfig.Config.Behaviour.RandomIntensity); 501 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/ModeDuration", 502 | _moduleConfig.Config.Behaviour.RandomDuration); 503 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/Intensity", 504 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.FixedIntensity / 100f)); 505 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/MinDuration", 506 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.DurationRange.Min / 10_000f)); 507 | await _oscClient.SendGameMessage("/avatar/parameters/ShockOsc/_Config/_All/MaxDuration", 508 | MathUtils.Saturate(_moduleConfig.Config.Behaviour.DurationRange.Max / 10_000f)); 509 | 510 | foreach (var (guid, programGroup) in _dataLayer.ProgramGroups) 511 | await SendUpdateForGroup(programGroup); 512 | } 513 | 514 | public async Task SendUpdateForGroup(ProgramGroup programGroup) 515 | { 516 | if (programGroup.ConfigGroup == null) return; 517 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/Paused", programGroup.Paused); 518 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/MinIntensity", 519 | MathUtils.Saturate(programGroup.ConfigGroup.IntensityRange.Min / 100f)); 520 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/MaxIntensity", 521 | MathUtils.Saturate(programGroup.ConfigGroup.IntensityRange.Max / 100f)); 522 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/Duration", 523 | MathUtils.Saturate(programGroup.ConfigGroup.FixedDuration / 10000f)); 524 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/CooldownTime", 525 | MathUtils.Saturate(programGroup.ConfigGroup.CooldownTime / 100000f)); 526 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/ModeIntensity", 527 | programGroup.ConfigGroup.RandomIntensity); 528 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/ModeDuration", 529 | programGroup.ConfigGroup.RandomDuration); 530 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/Intensity", 531 | MathUtils.Saturate(programGroup.ConfigGroup.FixedIntensity / 100f)); 532 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/MinDuration", 533 | MathUtils.Saturate(programGroup.ConfigGroup.DurationRange.Min / 10_000f)); 534 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/MaxDuration", 535 | MathUtils.Saturate(programGroup.ConfigGroup.DurationRange.Max / 10_000f)); 536 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/OverrideIntensity", 537 | programGroup.ConfigGroup.OverrideIntensity); 538 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/OverrideDuration", 539 | programGroup.ConfigGroup.OverrideDuration); 540 | await _oscClient.SendGameMessage($"/avatar/parameters/ShockOsc/_Config/{programGroup.Name}/OverrideCooldownTime", 541 | programGroup.ConfigGroup.OverrideCooldownTime); 542 | } 543 | } -------------------------------------------------------------------------------- /ShockOsc/ShockOSCModule.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using MudBlazor; 5 | using OpenShock.Desktop.ModuleBase; 6 | using OpenShock.Desktop.ModuleBase.Config; 7 | using OpenShock.Desktop.ModuleBase.Navigation; 8 | using OpenShock.ShockOSC; 9 | using OpenShock.ShockOSC.Config; 10 | using OpenShock.ShockOSC.Services; 11 | using OpenShock.ShockOSC.Ui.Pages.Dash.Tabs; 12 | using OscQueryLibrary; 13 | // ReSharper disable InconsistentNaming 14 | 15 | [assembly:DesktopModule(typeof(ShockOSCModule), "openshock.shockosc", "ShockOSC")] 16 | namespace OpenShock.ShockOSC; 17 | 18 | public sealed class ShockOSCModule : DesktopModuleBase, IAsyncDisposable 19 | { 20 | private IAsyncDisposable? _onRemoteControlSubscription; 21 | public override string IconPath => "OpenShock/ShockOSC/Resources/ShockOSC-Icon.svg"; 22 | 23 | public override IReadOnlyCollection NavigationComponents { get; } = 24 | [ 25 | new() 26 | { 27 | Name = "Settings", 28 | ComponentType = typeof(ConfigTab), 29 | Icon = IconOneOf.FromSvg(Icons.Material.Filled.Settings) 30 | }, 31 | new() 32 | { 33 | Name = "Groups", 34 | ComponentType = typeof(GroupsTab), 35 | Icon = IconOneOf.FromSvg(Icons.Material.Filled.Group) 36 | }, 37 | new() 38 | { 39 | Name = "Chatbox", 40 | ComponentType = typeof(ChatboxTab), 41 | Icon = IconOneOf.FromSvg(Icons.Material.Filled.Chat) 42 | }, 43 | new() 44 | { 45 | Name = "Debug", 46 | ComponentType = typeof(DebugTab), 47 | Icon = IconOneOf.FromSvg(Icons.Material.Filled.BugReport) 48 | } 49 | ]; 50 | 51 | public override async Task Setup() 52 | { 53 | 54 | var config = await ModuleInstanceManager.GetModuleConfig(); 55 | ModuleServiceProvider = BuildServices(config); 56 | 57 | } 58 | 59 | private IServiceProvider BuildServices(IModuleConfig config) 60 | { 61 | var loggerFactory = ModuleInstanceManager.AppServiceProvider.GetRequiredService(); 62 | 63 | var services = new ServiceCollection(); 64 | 65 | services.AddSingleton(loggerFactory); 66 | services.AddLogging(); 67 | services.AddSingleton(config); 68 | 69 | services.AddSingleton(ModuleInstanceManager.OpenShock); 70 | services.AddSingleton(); 71 | services.AddSingleton(); 72 | services.AddSingleton(); 73 | services.AddSingleton(); 74 | 75 | services.AddSingleton(_ => 76 | { 77 | var listenAddress = config.Config.Osc.QuestSupport ? IPAddress.Any : IPAddress.Loopback; 78 | return new OscQueryServer("ShockOSC", listenAddress); 79 | }); 80 | 81 | services.AddSingleton(); 82 | services.AddSingleton(); 83 | services.AddSingleton(); 84 | 85 | 86 | return services.BuildServiceProvider(); 87 | } 88 | 89 | public override async Task Start() 90 | { 91 | var config = ModuleServiceProvider.GetRequiredService>(); 92 | 93 | await ModuleServiceProvider.GetRequiredService().Start(); 94 | 95 | if (config.Config.Osc.OscQuery) ModuleServiceProvider.GetRequiredService().Start(); 96 | 97 | var chatboxService = ModuleServiceProvider.GetRequiredService(); 98 | 99 | _onRemoteControlSubscription = await ModuleInstanceManager.OpenShock.Control.OnRemoteControlledShocker.SubscribeAsync(async args => 100 | { 101 | foreach (var controlLog in args.Logs) 102 | { 103 | await chatboxService.SendRemoteControlMessage(controlLog.Shocker.Name, args.Sender.Name, 104 | args.Sender.CustomName, controlLog.Intensity, controlLog.Duration, controlLog.Type); 105 | } 106 | }); 107 | } 108 | 109 | private bool _disposed; 110 | 111 | public async ValueTask DisposeAsync() 112 | { 113 | if(_disposed) return; 114 | _disposed = true; 115 | 116 | if (_onRemoteControlSubscription != null) await _onRemoteControlSubscription.DisposeAsync(); 117 | } 118 | } -------------------------------------------------------------------------------- /ShockOsc/ShockOsc.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | enable 6 | false 7 | enable 8 | 9 | OpenShock.ShockOSC 10 | OpenShock.ShockOSC 11 | OpenShock 12 | 13 | 3.0.0 14 | 3.0.0-rc.3 15 | 16 | ShockOsc 17 | 18 | en 19 | en-US;en 20 | false 21 | 22 | Debug;Release 23 | 24 | AnyCPU 25 | net9.0 26 | 10.0.17763.0 27 | 10.0.17763.0 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor: -------------------------------------------------------------------------------- 1 | @using System.Globalization 2 | @using OpenShock.Desktop.ModuleBase 3 | @using OpenShock.Desktop.ModuleBase.Config 4 | @using OpenShock.Desktop.ModuleBase.Models 5 | @using OpenShock.ShockOSC.Config 6 | @using OpenShock.ShockOSC.Ui.Utils 7 | 8 | @page "/dash/chatbox" 9 | 10 | 11 | Chatbox General 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | Timeout: @MathF.Round(ModuleConfig.Config.Chatbox.Timeout / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 20 | 21 |
22 | 23 | 24 | Hoscy 25 | 26 | 27 | 28 | 29 | 30 | @foreach (ChatboxConf.HoscyMessageType hoscyMessageType in Enum.GetValues(typeof(ChatboxConf.HoscyMessageType))) 31 | { 32 | @hoscyMessageType 33 | } 34 | 35 | 36 | 37 | 38 | 39 | Message Options and Templates 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | @foreach (ControlType controlType in Enum.GetValues(typeof(ControlType))) 50 | { 51 | 52 | 53 | 54 | 55 | 56 | 57 | } 58 | 59 | 60 |
61 |
62 | 63 | @code { 64 | 65 | [ModuleInject] private IModuleConfig ModuleConfig { get; set; } = null!; 66 | 67 | private void OnSettingsValueChange() 68 | { 69 | ModuleConfig.SaveDeferred(); 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor: -------------------------------------------------------------------------------- 1 | @using System.Globalization 2 | @using OpenShock.Desktop.ModuleBase 3 | @using OpenShock.Desktop.ModuleBase.Config 4 | @using OpenShock.ShockOSC.Config 5 | @using OpenShock.ShockOSC.Services 6 | @using OpenShock.ShockOSC.Ui.Utils 7 | @using OpenShock.ShockOSC.Utils 8 | @implements IDisposable 9 | 10 | @page "/dash/config" 11 | 12 | 13 | Global Shocker Options (_All Group) 14 | 15 |
16 | 17 | @foreach (var boneHeldAction in BoneActionExtensions.BoneActions) 18 | { 19 | @boneHeldAction 20 | } 21 | 22 | 23 | @foreach (var boneReleasedAction in BoneActionExtensions.BoneActions) 24 | { 25 | @boneReleasedAction 26 | } 27 | 28 |
29 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | @if (!ModuleConfig.Config.Behaviour.RandomIntensity) 51 | { 52 | 54 | Intensity: @ModuleConfig.Config.Behaviour.FixedIntensity% 55 | 56 | } 57 | else 58 | { 59 | 62 | Min Intensity: @ModuleConfig.Config.Behaviour.IntensityRange.Min% 63 | 64 | 65 | 68 | Max Intensity: @ModuleConfig.Config.Behaviour.IntensityRange.Max% 69 | 70 | } 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 79 | @if (!ModuleConfig.Config.Behaviour.RandomDuration) 80 | { 81 | 84 | Duration: @MathF.Round(ModuleConfig.Config.Behaviour.FixedDuration / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 85 | 86 | } 87 | else 88 | { 89 | 93 | Min Duration: @MathF.Round(ModuleConfig.Config.Behaviour.DurationRange.Min / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 94 | 95 | 96 | 100 | Max Duration: @MathF.Round(ModuleConfig.Config.Behaviour.DurationRange.Max / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 101 | 102 | } 103 | 104 |
105 |
106 |
107 | 108 | 109 | Game Options 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | OSC Options (changing requires restart) 119 | 120 | 121 | 122 | @if (!ModuleConfig.Config.Osc.OscQuery) 123 | { 124 |
125 | 126 | 127 | } 128 |
129 | 130 | 131 | Medal.TV ICYMI (Automatic bookmarking and clip capture) 132 | 133 | 134 | 135 | 136 |
137 | 138 | 139 | 140 |
141 | 142 |
143 | 144 | @foreach (IcymiTriggerAction triggerAction in Enum.GetValues(typeof(IcymiTriggerAction))) 145 | { 146 | @triggerAction 147 | } 148 | 149 | 150 | @foreach (IcymiAlertType alertType in Enum.GetValues(typeof(IcymiAlertType))) 151 | { 152 | @alertType 153 | } 154 | 155 |
156 | 157 |
158 | Target Game (changing requires restart) 159 | 160 | 161 | @foreach (IcymiGame icymiGame in Enum.GetValues(typeof(IcymiGame))) 162 | { 163 | @icymiGame 164 | } 165 | 166 |
167 |
168 | 169 | @code { 170 | 171 | [ModuleInject] private UnderscoreConfig UnderscoreConfig { get; set; } = null!; 172 | [ModuleInject] private IModuleConfig ModuleConfig { get; set; } = null!; 173 | 174 | private string RandomIntensityString 175 | { 176 | get => ModuleConfig.Config.Behaviour.RandomIntensity ? "Random Intensity" : "Fixed Intensity"; 177 | set => ModuleConfig.Config.Behaviour.RandomIntensity = value == "Random Intensity"; 178 | } 179 | 180 | private string RandomDurationString 181 | { 182 | get => ModuleConfig.Config.Behaviour.RandomDuration ? "Random Duration" : "Fixed Duration"; 183 | set => ModuleConfig.Config.Behaviour.RandomDuration = value == "Random Duration"; 184 | } 185 | 186 | private uint _limitBoneHeldDuration; 187 | private bool _limitBoneHeldDurationEnabled; 188 | 189 | private void OnSettingsValueChange() 190 | { 191 | OsTask.Run(OnSettingsValueChangeAsync); 192 | } 193 | 194 | private Task OnSettingsValueChangeAsync() 195 | { 196 | ModuleConfig.Config.Behaviour.BoneHeldDurationLimit = _limitBoneHeldDurationEnabled ? _limitBoneHeldDuration : null; 197 | 198 | ModuleConfig.SaveDeferred(); 199 | return UnderscoreConfig.SendUpdateForAll(); 200 | } 201 | 202 | protected override void OnInitialized() 203 | { 204 | _limitBoneHeldDurationEnabled = ModuleConfig.Config.Behaviour.BoneHeldDurationLimit.HasValue; 205 | _limitBoneHeldDuration = ModuleConfig.Config.Behaviour.BoneHeldDurationLimit ?? 2000; 206 | 207 | UnderscoreConfig.OnConfigUpdate += OnConfigUpdate; 208 | } 209 | 210 | private void OnConfigUpdate() 211 | { 212 | InvokeAsync(StateHasChanged); 213 | } 214 | 215 | public void Dispose() 216 | { 217 | UnderscoreConfig.OnConfigUpdate -= OnConfigUpdate; 218 | } 219 | 220 | 221 | } -------------------------------------------------------------------------------- /ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor: -------------------------------------------------------------------------------- 1 | @using OpenShock.Desktop.ModuleBase 2 | @using OpenShock.Desktop.ModuleBase.Api 3 | @using OpenShock.ShockOSC.Services 4 | @using OpenShock.ShockOSC.Utils 5 | @implements IAsyncDisposable 6 | 7 | @page "/dash/debug" 8 | 9 | 10 | Avatar ID: @ShockOsc.AvatarId 11 | 12 | 13 | OSC Parameters 14 | 15 | 16 | 17 | 18 |
19 | @if (_showAllAvatarParams) 20 | { 21 | if (ShockOsc.AllAvatarParams.Count > 0) 22 | { 23 | @foreach (var param in ShockOsc.AllAvatarParams 24 | .Where(x => x.Key.Contains(_search, StringComparison.InvariantCultureIgnoreCase))) 25 | { 26 | 27 | } 28 | } 29 | else 30 | { 31 | No parameters available 32 | } 33 | } 34 | else 35 | { 36 | if (ShockOsc.ShockOscParams.Count > 0) 37 | { 38 | @foreach (var param in ShockOsc.ShockOscParams 39 | .Where(x => x.Key.Contains(_search, StringComparison.InvariantCultureIgnoreCase))) 40 | { 41 | 42 | } 43 | } 44 | else 45 | { 46 | No parameters available 47 | } 48 | } 49 | 50 |
51 | 52 | @code { 53 | 54 | [ModuleInject] private ShockOsc ShockOsc { get; set; } = null!; 55 | [ModuleInject] private IOpenShockService OpenShock { get; set; } = null!; 56 | 57 | private bool _showAllAvatarParams = false; 58 | private string _search = ""; 59 | 60 | private void OnParamsChange(bool shockOscParam) 61 | { 62 | // only redraw page when needed 63 | if (!_showAllAvatarParams && !shockOscParam) 64 | return; 65 | 66 | _updateQueued = true; 67 | } 68 | 69 | private bool _updateQueued = true; 70 | 71 | protected override void OnInitialized() 72 | { 73 | _onParamsVhangeSubscription = ShockOsc.OnParamsChangeObservable.Subscribe(OnParamsChange); 74 | 75 | OsTask.Run(UpdateParams); 76 | } 77 | 78 | private async Task UpdateParams() 79 | { 80 | while (!_cts.IsCancellationRequested) 81 | { 82 | if (!_updateQueued) 83 | continue; 84 | _updateQueued = false; 85 | 86 | await InvokeAsync(StateHasChanged); 87 | 88 | await Task.Delay(100); 89 | } 90 | } 91 | 92 | private CancellationTokenSource _cts = new CancellationTokenSource(); 93 | private IDisposable? _onParamsVhangeSubscription; 94 | 95 | 96 | public async ValueTask DisposeAsync() 97 | { 98 | _onParamsVhangeSubscription?.Dispose(); 99 | await _cts.CancelAsync(); 100 | } 101 | 102 | } -------------------------------------------------------------------------------- /ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor: -------------------------------------------------------------------------------- 1 | @using System.Globalization 2 | @using System.Text.RegularExpressions 3 | @using OpenShock.Desktop.ModuleBase 4 | @using OpenShock.Desktop.ModuleBase.Api 5 | @using OpenShock.Desktop.ModuleBase.Config 6 | @using OpenShock.ShockOSC.Config 7 | @using OpenShock.ShockOSC.Services 8 | @using OpenShock.ShockOSC.Ui.Utils 9 | @using Group = OpenShock.ShockOSC.Config.Group 10 | @using ProgramGroup = OpenShock.ShockOSC.Models.ProgramGroup 11 | 12 | @page "/dash/groups" 13 | 14 | @code { 15 | 16 | [ModuleInject] private IModuleConfig ModuleConfig { get; set; } = null!; 17 | [ModuleInject] private ShockOsc ShockOsc { get; set; } = null!; 18 | [ModuleInject] private IOpenShockService OpenShock { get; set; } = null!; 19 | [ModuleInject] private UnderscoreConfig UnderscoreConfig { get; set; } = null!; 20 | 21 | private Guid? _group = null; 22 | 23 | public Guid? Group 24 | { 25 | get => _group; 26 | set 27 | { 28 | _group = value; 29 | OnGroupSelect(); 30 | } 31 | } 32 | 33 | public async Task AddGroup() 34 | { 35 | var groupId = Guid.NewGuid(); 36 | ModuleConfig.Config.Groups.Add(groupId, new Group { Name = "New Group" }); 37 | Group = groupId; 38 | 39 | await InvokeAsync(StateHasChanged); 40 | ModuleConfig.SaveDeferred(); 41 | ShockOsc.RaiseOnGroupsChanged(); 42 | } 43 | 44 | public async Task DeleteGroup() 45 | { 46 | if (Group == null) return; 47 | 48 | ModuleConfig.Config.Groups.Remove(Group.Value); 49 | Group = null; 50 | await InvokeAsync(StateHasChanged); 51 | ModuleConfig.SaveDeferred(); 52 | ShockOsc.RaiseOnGroupsChanged(); 53 | } 54 | 55 | private void OnSettingsValueChange() 56 | { 57 | ModuleConfig.SaveDeferred(); 58 | ShockOsc.RaiseOnGroupsChanged(); 59 | } 60 | 61 | private Task OnGroupSettingsValueChange() 62 | { 63 | if (CurrentGroup != null) 64 | { 65 | CurrentGroup.BoneHeldDurationLimit = _limitBoneHeldDurationEnabled ? _limitBoneHeldDuration : null; 66 | } 67 | 68 | ModuleConfig.SaveDeferred(); 69 | if (CurrentProgramGroup == null) throw new NullReferenceException("Program Group is null in GroupsTab"); 70 | return UnderscoreConfig.SendUpdateForGroup(CurrentProgramGroup); 71 | } 72 | 73 | private void OnGroupSelect() 74 | { 75 | if (CurrentGroup != null) _selectedShockers = [..CurrentGroup.Shockers]; 76 | 77 | InvokeAsync(StateHasChanged); 78 | } 79 | 80 | private void OnSelectedShockersUpdate() 81 | { 82 | if (CurrentGroup != null) 83 | { 84 | CurrentGroup.Shockers = _selectedShockers.ToList(); 85 | ModuleConfig.SaveDeferred(); 86 | } 87 | } 88 | 89 | private Group? CurrentGroup => Group == null ? null : ModuleConfig.Config.Groups.TryGetValue(Group.Value, out var group) ? group : null; 90 | 91 | private ProgramGroup? CurrentProgramGroup => Group == null ? null : UnderscoreConfig.GetProgramGroupFromGUID(Group.Value, out var group) ? group : null; 92 | 93 | private static Regex _nameRegex = new Regex(@"^[a-zA-Z0-9\/\\ -]+$", RegexOptions.Compiled); 94 | 95 | private string NameValidation(string name) 96 | { 97 | if (string.IsNullOrEmpty(name)) return "Name cannot be empty"; 98 | if (name.Length > 30) return "Name cannot be longer than 30 characters"; 99 | if (!_nameRegex.IsMatch(name)) return "Can only contain letters, numbers, and /\\- characters, this is due to unity animator parameters restrictions"; 100 | return string.Empty; 101 | } 102 | 103 | private HashSet _selectedShockers = []; 104 | 105 | 106 | private string CurrentRandomIntensityString 107 | { 108 | get => CurrentGroup?.RandomIntensity ?? false ? "Random Intensity" : "Fixed Intensity"; 109 | set 110 | { 111 | if (CurrentGroup == null) return; 112 | CurrentGroup.RandomIntensity = value == "Random Intensity"; 113 | } 114 | } 115 | 116 | private string CurrentRandomDurationString 117 | { 118 | get => CurrentGroup?.RandomDuration ?? false ? "Random Duration" : "Fixed Duration"; 119 | set 120 | { 121 | if (CurrentGroup == null) return; 122 | CurrentGroup.RandomDuration = value == "Random Duration"; 123 | } 124 | } 125 | 126 | private async Task ResetIntensity() 127 | { 128 | if (CurrentGroup == null) return; 129 | CurrentGroup.FixedIntensity = ModuleConfig.Config.Behaviour.FixedIntensity; 130 | CurrentGroup.IntensityRange = ModuleConfig.Config.Behaviour.IntensityRange; 131 | CurrentGroup.RandomIntensity = ModuleConfig.Config.Behaviour.RandomIntensity; 132 | 133 | await InvokeAsync(StateHasChanged); 134 | ModuleConfig.SaveDeferred(); 135 | } 136 | 137 | private async Task ResetDuration() 138 | { 139 | if (CurrentGroup == null) return; 140 | CurrentGroup.FixedDuration = ModuleConfig.Config.Behaviour.FixedDuration; 141 | CurrentGroup.DurationRange = ModuleConfig.Config.Behaviour.DurationRange; 142 | CurrentGroup.RandomDuration = ModuleConfig.Config.Behaviour.RandomDuration; 143 | 144 | await InvokeAsync(StateHasChanged); 145 | ModuleConfig.SaveDeferred(); 146 | } 147 | 148 | private uint _limitBoneHeldDuration; 149 | private bool _limitBoneHeldDurationEnabled; 150 | 151 | protected override void OnInitialized() 152 | { 153 | _limitBoneHeldDurationEnabled = ModuleConfig.Config.Behaviour.BoneHeldDurationLimit.HasValue; 154 | _limitBoneHeldDuration = ModuleConfig.Config.Behaviour.BoneHeldDurationLimit ?? 2000; 155 | 156 | UnderscoreConfig.OnGroupConfigUpdate += OnGroupConfigUpdate; 157 | } 158 | 159 | private void OnGroupConfigUpdate() 160 | { 161 | InvokeAsync(StateHasChanged); 162 | } 163 | 164 | private void Dispose() 165 | { 166 | UnderscoreConfig.OnGroupConfigUpdate -= OnGroupConfigUpdate; 167 | } 168 | } 169 | 170 | Add New Group 171 | Delete Group 172 |

173 | 174 | @foreach (var group in ModuleConfig.Config.Groups) 175 | { 176 | @group.Value.Name 177 | } 178 | 179 | 180 | @if (CurrentGroup != null && CurrentProgramGroup != null) 181 | { 182 | 183 | Group Settings 184 | 185 |
186 | 187 | 188 |
189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 |
200 | 201 |
202 | 203 | 205 | 206 | 207 | 208 | Reset 209 |
210 |
211 | @if (!CurrentGroup.RandomIntensity) 212 | { 213 | 215 | Intensity: @CurrentGroup.FixedIntensity% 216 | 217 | } 218 | else 219 | { 220 | 223 | Min Intensity: @CurrentGroup.IntensityRange.Min% 224 | 225 | 226 | 229 | Max Intensity: @CurrentGroup.IntensityRange.Max% 230 | 231 | } 232 | 233 |
234 |
235 | 236 | 237 | 238 | 239 | 240 |
241 | 242 |
243 | 244 | 246 | 247 | 248 | 249 | Reset 250 |
251 |
252 | @if (!CurrentGroup.RandomDuration) 253 | { 254 | 257 | Duration: @MathF.Round(CurrentGroup.FixedDuration / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 258 | 259 | } 260 | else 261 | { 262 | 266 | Min Duration: @MathF.Round(CurrentGroup.DurationRange.Min / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 267 | 268 | 269 | 273 | Max Duration: @MathF.Round(CurrentGroup.DurationRange.Max / 1000f, 1).ToString(CultureInfo.InvariantCulture)s 274 | 275 | } 276 | 277 |
278 |
279 | 280 | 281 | 282 | 283 | 284 |
285 | 286 | 287 | 288 |
289 |
290 | 291 | 292 | 293 | 294 | 295 |
296 | 297 | 298 | @foreach (var boneHeldAction in BoneActionExtensions.BoneActions) 299 | { 300 | @boneHeldAction 301 | } 302 | 303 |
304 |
305 | 306 | 307 | 308 | 309 | 310 |
311 | 312 | 313 | @foreach (var boneReleasedAction in BoneActionExtensions.BoneActions) 314 | { 315 | @boneReleasedAction 316 | } 317 | 318 |
319 |
320 | 321 | 322 | 323 | 324 | 325 |
326 | 327 | 328 |
329 |
330 |
331 | 332 | 333 | 334 | Shockers in Group 335 | 336 |
337 | 338 | 339 | 340 | Name 341 | 342 | 343 | @OpenShock.Data.Hubs.Value.SelectMany(x => x.Shockers).First(x => x.Id == context).Name 344 | 345 | 346 |
347 | } 348 | else 349 | { 350 | 351 | Please select a group to edit 352 | 353 | } 354 | 355 | -------------------------------------------------------------------------------- /ShockOsc/Ui/Utils/DebouncedSlider.razor: -------------------------------------------------------------------------------- 1 | @typeparam T where T : struct, System.Numerics.INumber 2 | 3 | @ChildContent -------------------------------------------------------------------------------- /ShockOsc/Ui/Utils/DebouncedSlider.razor.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Numerics; 3 | using System.Reactive.Linq; 4 | using System.Reactive.Subjects; 5 | using System.Text.Json.Serialization; 6 | using Microsoft.AspNetCore.Components; 7 | using MudBlazor; 8 | using Size = MudBlazor.Size; 9 | 10 | namespace OpenShock.ShockOSC.Ui.Utils; 11 | 12 | public partial class DebouncedSlider : ComponentBase, IDisposable where T : struct, INumber 13 | { 14 | 15 | private BehaviorSubject? _subject; 16 | 17 | private T ValueProp 18 | { 19 | get => _subject!.Value; 20 | set 21 | { 22 | SliderValue = value; 23 | OnValueChanged?.Invoke(value); 24 | } 25 | } 26 | 27 | protected override void OnInitialized() 28 | { 29 | _subject = new BehaviorSubject(SliderValue); 30 | _subject.Throttle(DebounceTime).Subscribe(value => OnSaveAction?.Invoke(value)); 31 | } 32 | 33 | [Parameter] 34 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)] 35 | public string Label { get; set; } = string.Empty; 36 | 37 | [Parameter] 38 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 39 | public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(500); 40 | 41 | [Parameter] 42 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] 43 | public EventCallback SliderValueChanged { get; set; } 44 | 45 | [Parameter] 46 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] 47 | public Action? OnValueChanged { get; set; } 48 | 49 | private T _sliderValue = default!; 50 | 51 | [Parameter] 52 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)] 53 | #pragma warning disable BL0007 54 | public T SliderValue 55 | #pragma warning restore BL0007 56 | { 57 | get => _sliderValue; 58 | set 59 | { 60 | _subject?.OnNext(value); 61 | if(_sliderValue.Equals(value)) return; 62 | 63 | SliderValueChanged.InvokeAsync(value); 64 | _sliderValue = value!; 65 | } 66 | } 67 | 68 | [Parameter] 69 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 70 | public Action? OnSaveAction { get; set; } 71 | 72 | [Parameter] 73 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 74 | public Size Size { get; set; } = Size.Small; 75 | 76 | [Parameter] 77 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 78 | public string? Style { get; set; } 79 | 80 | [Parameter] 81 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 82 | public string? Class { get; set; } 83 | 84 | [Parameter] 85 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)] 86 | public RenderFragment? ChildContent { get; set; } 87 | 88 | [Parameter] 89 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 90 | public T Min { get; set; } = T.Zero; 91 | 92 | [Parameter] 93 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 94 | public T Max { get; set; } = T.CreateTruncating(100); 95 | 96 | [Parameter] 97 | [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] 98 | public T Step { get; set; } = T.One; 99 | 100 | private bool _disposed; 101 | 102 | public void Dispose() 103 | { 104 | if (_disposed) return; 105 | _disposed = true; 106 | 107 | _subject?.Dispose(); 108 | } 109 | } -------------------------------------------------------------------------------- /ShockOsc/Ui/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using Microsoft.AspNetCore.Components.Forms 3 | @using Microsoft.AspNetCore.Components.Routing 4 | @using Microsoft.AspNetCore.Components.Web 5 | @using Microsoft.AspNetCore.Components.Web.Virtualization 6 | @using Microsoft.JSInterop 7 | @using OpenShock.Desktop 8 | @using MudBlazor -------------------------------------------------------------------------------- /ShockOsc/Utils/MathUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace OpenShock.ShockOSC.Utils; 4 | 5 | public static class MathUtils 6 | { 7 | public static float LerpFloat(float min, float max, float t) => min + (max - min) * t; 8 | public static float Saturate(float value) => value < 0 ? 0 : value > 1 ? 1 : value; 9 | public static uint LerpUShort(ushort min, ushort max, float t) => (ushort)(min + (max - min) * t); 10 | public static uint ClampUint(uint value, uint min, uint max) => value < min ? min : value > max ? max : value; 11 | public static ushort ClampUShort(ushort value, ushort min, ushort max) => value < min ? min : value > max ? max : value; 12 | public static byte ClampByte(byte value, byte min, byte max) => value < min ? min : value > max ? max : value; 13 | 14 | public static float DurationInSeconds(this uint duration) => MathF.Round(duration / 1000f, 1); 15 | public static string DurationInSecondsString(this uint duration) => DurationInSeconds(duration).ToString(CultureInfo.InvariantCulture); 16 | } -------------------------------------------------------------------------------- /ShockOsc/Utils/OsTask.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using Serilog; 3 | using ILogger = Serilog.ILogger; 4 | 5 | namespace OpenShock.ShockOSC.Utils; 6 | 7 | public static class OsTask 8 | { 9 | private static readonly ILogger Logger = Log.ForContext(typeof(OsTask)); 10 | 11 | public static Task Run(Func function, CancellationToken token = default, [CallerFilePath] string file = "", 12 | [CallerMemberName] string member = "", [CallerLineNumber] int line = -1) 13 | { 14 | var task = Task.Run(function, token); 15 | task.ContinueWith( 16 | t => 17 | { 18 | if (!t.IsFaulted) return; 19 | var index = file.LastIndexOf('\\'); 20 | if (index == -1) index = file.LastIndexOf('/'); 21 | Logger.Error(t.Exception, 22 | "Error during task execution. {File}::{Member}:{Line}", 23 | file.Substring(index + 1, file.Length - index - 1), member, line); 24 | }, TaskContinuationOptions.OnlyOnFaulted); 25 | return task; 26 | } 27 | } -------------------------------------------------------------------------------- /copy-module-dll.cmd: -------------------------------------------------------------------------------- 1 | copy ShockOsc\bin\Debug\net9.0\OpenShock.ShockOSC.dll %appdata%\OpenShock\Desktop\modules\openshock.shockosc\OpenShock.ShockOSC.dll --------------------------------------------------------------------------------