From 328490a3b6514ce45a10354cdc4c1b7de2ea2eb4 Mon Sep 17 00:00:00 2001 From: Mira Date: Sat, 27 Aug 2022 19:53:00 +0200 Subject: [PATCH] Rework codebase (#13) * Update Logger Implementation * Start Recode * Finish all the needed obs stuff * Add SharedStatus * Null Checks in Update Method * move * Flush * File Handling for Shared Status * HttpStatus Support Re-Implemented * Update README.md * Update README.md * Re-Implemented DataPuller Support * Added FC Label if 0 misses * Added unixTimestamp Check * Cleanup * Added BeatSaberPlus Music * Add Disclaimer for BSP * start obs ws v5 support * obs websocket v5 receiving finished * obs ws v5 sending finished * Resume Recording when entering menu if PauseRecordingOnIngamePause is enabled * Migrate Old OBSPort to OBSPortLegacy * Update Readme to reflect obs ws v5 support * Add DownloadButton Graphic * Update DownloadButtonYesIKnowImNotGoodAtMakingGraphics.png * Update README.md * Update README.md * Recommend updating * Add Update Check * Re-Implement Steam Notifications * a * move loadedconfig to program * Re-Implement UI * Create build.cmd * Update Program.cs --- .editorconfig | 6 + BeatRecorder.sln | 45 +- ...uttonYesIKnowImNotGoodAtMakingGraphics.png | Bin 0 -> 77504 bytes BeatRecorder/BeatRecorder.csproj | 1 + .../DataPullerWebSocket/DataPuller.cs | 511 ----- .../Entities/BeatSaber/BeatSaberPlus.cs | 38 + .../Entities/BeatSaber/DataPullerData.cs | 20 + .../Entities/BeatSaber/DataPullerMain.cs | 63 + .../Entities/BeatSaber/DataPullerStatus.cs | 95 - .../{HttpStatusStatus.cs => HttpStatus.cs} | 28 +- .../Entities/BeatSaber/SharedStatus.cs | 318 +++ .../Entities/{Settings.cs => Config.cs} | 23 +- .../Entities/{Steam => }/NotificationEntry.cs | 8 +- BeatRecorder/Entities/OBS/Event.cs | 14 + BeatRecorder/Entities/OBS/Events/EventType.cs | 12 + .../Entities/OBS/Events/RecordStateChanged.cs | 21 + BeatRecorder/Entities/OBS/Indentified.cs | 13 + BeatRecorder/Entities/OBS/Indentify.cs | 21 + .../{ => Legacy}/AuthenticationRequired.cs | 6 +- .../Entities/OBS/Legacy/ObsResponse.cs | 13 + .../OBS/{ => Legacy}/RecordingStopped.cs | 2 +- .../Legacy/Requests/AuthenticateRequest.cs | 14 + .../OBS/Legacy/Requests/BaseRequest.cs | 12 + .../Legacy/Requests/GetAuthRequiredRequest.cs | 9 + .../Legacy/Requests/PauseRecordingRequest.cs | 9 + .../Legacy/Requests/ResumeRecordingRequest.cs | 9 + .../Legacy/Requests/SetCurrentSceneRequest.cs | 14 + .../Legacy/Requests/StartRecordingRequest.cs | 9 + .../Legacy/Requests/StopRecordingRequest.cs | 9 + .../Entities/OBS/OBSWebSocketStatus.cs | 10 - BeatRecorder/Entities/OBS/ObsResponse.cs | 7 + BeatRecorder/Entities/OBS/RecordingStatus.cs | 7 - .../OBS/Requests/AuthenticateRequest.cs | 19 + .../Entities/OBS/Requests/BaseRequest.cs | 9 + .../Entities/OBS/Requests/PauseRecord.cs | 14 + .../Entities/OBS/Requests/ResumeRecord.cs | 14 + .../OBS/Requests/SetCurrentProgramScene.cs | 18 + .../Entities/OBS/Requests/StartRecord.cs | 14 + .../Entities/OBS/Requests/StopRecord.cs | 14 + BeatRecorder/Entities/Status.cs | 6 + BeatRecorder/Enums/ConnectionTypeWarning.cs | 10 +- BeatRecorder/Enums/GameEnvironment.cs | 9 + BeatRecorder/Enums/Mod.cs | 8 + BeatRecorder/Global.cs | 14 +- .../HttpStatusWebSocket/HttpStatus.cs | 492 ----- BeatRecorder/OBSWebSocket/OBSWebSocket.cs | 76 - .../OBSWebSocket/OBSWebSocketEvents.cs | 270 --- BeatRecorder/Objects.cs | 14 - BeatRecorder/Program.cs | 456 ++--- BeatRecorder/UIHandler.cs | 311 --- .../Util/BeatSaber/BaseBeatSaberHandler.cs | 154 ++ .../Util/BeatSaber/BeatSaberPlusHandler.cs | 249 +++ .../Util/BeatSaber/DataPullerHandler.cs | 268 +++ .../Util/BeatSaber/HttpStatusHandler.cs | 231 +++ BeatRecorder/Util/ConsoleHelper.cs | 2 +- BeatRecorder/Util/EasyOpenVR | 1 - BeatRecorder/Util/Extensions.cs | 54 +- BeatRecorder/Util/Log.cs | 6 + BeatRecorder/Util/OBS/BaseObsHandler.cs | 15 + BeatRecorder/Util/OBS/LegacyObsHandler.cs | 430 ++++ BeatRecorder/Util/OBS/ObsHandler.cs | 431 ++++ .../OpenVR/EasyOpenVR/EasyOpenVRSingleton.cs | 1741 +++++++++++++++++ BeatRecorder/Util/OpenVR/EasyOpenVR/README.md | 19 + .../Util/OpenVR/SteamNotifications.cs | 113 ++ BeatRecorder/Util/{ => OpenVR}/openvr_api.cs | 3 +- BeatRecorder/Util/UIHandler.cs | 167 ++ BeatRecorderUI/BeatRecorderUI.csproj | 17 +- BeatRecorderUI/Entities/Config.cs | 1 + BeatRecorderUI/InfoUI.Designer.cs | 41 +- BeatRecorderUI/InfoUI.cs | 21 +- BeatRecorderUI/LoglevelEnum.cs | 14 - BeatRecorderUI/Objects.cs | 80 - BeatRecorderUI/SettingsUI.Designer.cs | 58 +- BeatRecorderUI/SettingsUI.cs | 29 +- BeatRecorderUI/Usings.cs | 2 - README.md | 104 +- build.cmd | 3 + 77 files changed, 4912 insertions(+), 2477 deletions(-) create mode 100644 BeatRecorder/Assets/DownloadButtonYesIKnowImNotGoodAtMakingGraphics.png delete mode 100644 BeatRecorder/DataPullerWebSocket/DataPuller.cs create mode 100644 BeatRecorder/Entities/BeatSaber/BeatSaberPlus.cs create mode 100644 BeatRecorder/Entities/BeatSaber/DataPullerData.cs create mode 100644 BeatRecorder/Entities/BeatSaber/DataPullerMain.cs delete mode 100644 BeatRecorder/Entities/BeatSaber/DataPullerStatus.cs rename BeatRecorder/Entities/BeatSaber/{HttpStatusStatus.cs => HttpStatus.cs} (81%) create mode 100644 BeatRecorder/Entities/BeatSaber/SharedStatus.cs rename BeatRecorder/Entities/{Settings.cs => Config.cs} (87%) rename BeatRecorder/Entities/{Steam => }/NotificationEntry.cs (60%) create mode 100644 BeatRecorder/Entities/OBS/Event.cs create mode 100644 BeatRecorder/Entities/OBS/Events/EventType.cs create mode 100644 BeatRecorder/Entities/OBS/Events/RecordStateChanged.cs create mode 100644 BeatRecorder/Entities/OBS/Indentified.cs create mode 100644 BeatRecorder/Entities/OBS/Indentify.cs rename BeatRecorder/Entities/OBS/{ => Legacy}/AuthenticationRequired.cs (70%) create mode 100644 BeatRecorder/Entities/OBS/Legacy/ObsResponse.cs rename BeatRecorder/Entities/OBS/{ => Legacy}/RecordingStopped.cs (81%) create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/AuthenticateRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/BaseRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/GetAuthRequiredRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/PauseRecordingRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/ResumeRecordingRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/SetCurrentSceneRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/StartRecordingRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Legacy/Requests/StopRecordingRequest.cs delete mode 100644 BeatRecorder/Entities/OBS/OBSWebSocketStatus.cs create mode 100644 BeatRecorder/Entities/OBS/ObsResponse.cs delete mode 100644 BeatRecorder/Entities/OBS/RecordingStatus.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/AuthenticateRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/BaseRequest.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/PauseRecord.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/ResumeRecord.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/SetCurrentProgramScene.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/StartRecord.cs create mode 100644 BeatRecorder/Entities/OBS/Requests/StopRecord.cs create mode 100644 BeatRecorder/Entities/Status.cs create mode 100644 BeatRecorder/Enums/GameEnvironment.cs create mode 100644 BeatRecorder/Enums/Mod.cs delete mode 100644 BeatRecorder/HttpStatusWebSocket/HttpStatus.cs delete mode 100644 BeatRecorder/OBSWebSocket/OBSWebSocket.cs delete mode 100644 BeatRecorder/OBSWebSocket/OBSWebSocketEvents.cs delete mode 100644 BeatRecorder/Objects.cs delete mode 100644 BeatRecorder/UIHandler.cs create mode 100644 BeatRecorder/Util/BeatSaber/BaseBeatSaberHandler.cs create mode 100644 BeatRecorder/Util/BeatSaber/BeatSaberPlusHandler.cs create mode 100644 BeatRecorder/Util/BeatSaber/DataPullerHandler.cs create mode 100644 BeatRecorder/Util/BeatSaber/HttpStatusHandler.cs delete mode 160000 BeatRecorder/Util/EasyOpenVR create mode 100644 BeatRecorder/Util/Log.cs create mode 100644 BeatRecorder/Util/OBS/BaseObsHandler.cs create mode 100644 BeatRecorder/Util/OBS/LegacyObsHandler.cs create mode 100644 BeatRecorder/Util/OBS/ObsHandler.cs create mode 100644 BeatRecorder/Util/OpenVR/EasyOpenVR/EasyOpenVRSingleton.cs create mode 100644 BeatRecorder/Util/OpenVR/EasyOpenVR/README.md create mode 100644 BeatRecorder/Util/OpenVR/SteamNotifications.cs rename BeatRecorder/Util/{ => OpenVR}/openvr_api.cs (99%) create mode 100644 BeatRecorder/Util/UIHandler.cs create mode 120000 BeatRecorderUI/Entities/Config.cs delete mode 100644 BeatRecorderUI/LoglevelEnum.cs delete mode 100644 BeatRecorderUI/Objects.cs create mode 100644 build.cmd diff --git a/.editorconfig b/.editorconfig index 913bee4..e10625c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,3 +14,9 @@ dotnet_diagnostic.IDE0078.severity = silent # IDE0060: Remove unused parameter dotnet_diagnostic.IDE0060.severity = silent + +# IDE0017: Simplify object initialization +dotnet_diagnostic.IDE0017.severity = silent + +# IDE0180: Use tuple to swap values +dotnet_diagnostic.IDE0180.severity = silent diff --git a/BeatRecorder.sln b/BeatRecorder.sln index 8b61f56..c696239 100644 --- a/BeatRecorder.sln +++ b/BeatRecorder.sln @@ -4,11 +4,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 VisualStudioVersion = 17.1.31911.260 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeatRecorder", "BeatRecorder\BeatRecorder.csproj", "{BA868827-924B-41AE-BC79-03BCF671F1BB}" - ProjectSection(ProjectDependencies) = postProject - {962B9A85-768F-42F5-A7CA-451A61E2A3C0} = {962B9A85-768F-42F5-A7CA-451A61E2A3C0} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeatRecorderUI", "BeatRecorderUI\BeatRecorderUI.csproj", "{962B9A85-768F-42F5-A7CA-451A61E2A3C0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xorog.Logger", "..\Xorog.Logger\Xorog.Logger.csproj", "{BAC20836-4478-4DC8-BA37-C2B47B5EFF46}" EndProject @@ -17,6 +12,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xorog.UniversalExtensions", "..\Xorog.UniversalExtensions\Xorog.UniversalExtensions.csproj", "{87214ED2-B7E9-416F-8554-70D6ACDB36D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeatRecorderUI", "BeatRecorderUI\BeatRecorderUI.csproj", "{F2C43916-D43B-4B50-94FC-9EF038D06CE8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,18 +38,6 @@ Global {BA868827-924B-41AE-BC79-03BCF671F1BB}.x64|Any CPU.Build.0 = Release|Any CPU {BA868827-924B-41AE-BC79-03BCF671F1BB}.x64|x64.ActiveCfg = Release|Any CPU {BA868827-924B-41AE-BC79-03BCF671F1BB}.x64|x64.Build.0 = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Debug|x64.ActiveCfg = Debug|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Debug|x64.Build.0 = Debug|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Release|Any CPU.Build.0 = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Release|x64.ActiveCfg = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.Release|x64.Build.0 = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.x64|Any CPU.ActiveCfg = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.x64|Any CPU.Build.0 = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.x64|x64.ActiveCfg = Release|Any CPU - {962B9A85-768F-42F5-A7CA-451A61E2A3C0}.x64|x64.Build.0 = Release|Any CPU {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.Debug|x64.ActiveCfg = Debug|x64 @@ -63,6 +50,30 @@ Global {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.x64|Any CPU.Build.0 = x64|Any CPU {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.x64|x64.ActiveCfg = x64|x64 {BAC20836-4478-4DC8-BA37-C2B47B5EFF46}.x64|x64.Build.0 = x64|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Debug|x64.ActiveCfg = Debug|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Debug|x64.Build.0 = Debug|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Release|Any CPU.Build.0 = Release|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Release|x64.ActiveCfg = Release|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.Release|x64.Build.0 = Release|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.x64|Any CPU.ActiveCfg = x64|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.x64|Any CPU.Build.0 = x64|Any CPU + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.x64|x64.ActiveCfg = x64|x64 + {87214ED2-B7E9-416F-8554-70D6ACDB36D0}.x64|x64.Build.0 = x64|x64 + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Debug|x64.Build.0 = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Release|Any CPU.Build.0 = Release|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Release|x64.ActiveCfg = Release|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.Release|x64.Build.0 = Release|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.x64|Any CPU.Build.0 = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.x64|x64.ActiveCfg = Debug|Any CPU + {F2C43916-D43B-4B50-94FC-9EF038D06CE8}.x64|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BeatRecorder/Assets/DownloadButtonYesIKnowImNotGoodAtMakingGraphics.png b/BeatRecorder/Assets/DownloadButtonYesIKnowImNotGoodAtMakingGraphics.png new file mode 100644 index 0000000000000000000000000000000000000000..15ed3516f732446027adca49796bdc74ccbd60c0 GIT binary patch literal 77504 zcmZ^KX+To>7j}-5j8jTeGv!WBmeZJfF1Ub8<(iu2zEEPCqB)9ME?~Au5dXYhUI@s-Mr>iYWZ+?xiJ!`vbS7+Xy9~XB^@8$n< z^hfX7rTF@PhsD{ND z>wkMy=ln;#)$w;vdzv1)x7`Jw{&j6j)N=R!!Mn zj%1X*Fl$MQaNoZQ()mf;NhAN;=XwR8F3J*rtj5mkvy z|A=?FEe{wo|KuFy@oulWCM}#s9Zh~YlW1*pzOOi)X*HRCbg6uhoR20N4!1$edsdGC zfu3lMwy@Y(!c_lrV%vOU%M9w@!52i9b)23PDnu9l!YoE-TBz=Y55985z2aTs!Dqye zd(I$lgH?w5nGFn_8AhZjISo(a?gcP`D^dN$tgBXEhd@VWxb#(kk z2#NjngV9xiXUP5#p-jDz$VR3$d6!cOsam@ycNP@c_Xkw$pKfq`reYO@Mp9m%fqBf` zyn~@nVt{T0$5eb)^8aGtm6UvX#eemTKT`U9^m09qCA&D)|RMa?m?QzZ8g_p18 z3cJ%e-)&`86(qKp4>LWo+h288aSh!I3n^Y;)!;u3gJL zC+02xDLeV&LDJDwXqJC+X7pnmrt1CXnE{wb8&Br?bV>S><|UKl1)9;qiKf4DK=K-T zWOh$0kaUUDzNiXT?@BWuHG)5VzI8{z=>!3mf8yXXYwPS8-q(!QYy3|aZ)E-k`RDt~ z8=1MfUh2p+;mf&;n85#~BI)CoJ!)od7lRO5Q{&ezol?=^4PC>iN9^5U%k*2!meuRS z_~`0;*c$_D%-Edbd>GySu@l=^qiU@jA}WY-pD+_fK<Peda#w_6liavZe>0`NEeQ>rF+8@PNt0=<;Tx@V4}{l+}gx1 zuz)-3C!Ks&<0L?Ny}J-}K*PD6|AB#thBf~){@HTV;Fp9eOW-47rI~Q0Yu~=M7$#iP~tHJKJ!7u;s6Gmqq0$(AM%f>fuQ!C@iL-M^od#7+;bV~H!0sf!~ z2nO&Qv9eP)@PRBv(BI&$cO*YOIF(j&);f%kT}rLo9|akTfsdOl$P$7~w2>?2sYBIk zCURZ&L|a}hX_Wr4OeBj@_l1w^@rHu(*FHU4jiYD=qML;gdj+D==_y+Q!D&*D*V9d| zfhP&40}KHz`bYCI$OHbjh7huH$Z=jY&9-VQLq#;n>h->nTBFexsUhC1xw^u8k*jaY z{nkP}^^@A~dAAk{YPJBVby1t_0<<$2) zcFW+EwwBf1>7@J*TOWNOry958K3t^S1mWZ7>_O7dBt%zszuxA(9_$gtNYiCY<+`eg z+3yI){fHCKl%sqH{en@rur%6yR!5E>%7zfZu}ZFUgM67N+2K(wD96eNp9AL+n8PSr zF@bvjwhBDV3y(5`Ogns$C1J%T%(#^-3lGlyvz4BhRg$~%^%Q{Kg9b^5c%Gc^ zzP>BE0pbFBXKt|#Fw0TJaJVq*pe6Dybm@mllbIz(` z>Pfc2dtywRp-;O#tIns7nQ<@s?-6xK-%|elXmX8jl-WUk1fh77$gJtlZQyzOsCRuC z%9=HtP!i{MMubQXP$)-%ztOt9)^Ck^oNvzbVykF9BO3S&pN?m!+S0MLB|8 zp@aMu2Xo}L!Jolv6e6?NNIn$ZZ!+<77SvMyp1A}?%Eg6`|4v!QY5cXsj}2iJ7;f^j zG|H2WLHU+wB{2IIRvT91O6zm!8VGwr}m? z8&IMC##Y+l&lhiXTn*+7>cQ@oEwp$gqnb1-1J!lXzJ2<91C(30oix?8weQK!SSNi& zan)3Odc0Ekzl+*@1C>Etjv&U_D;`wz| zox-+Gwvwy`W>;T*2bOJo$VmLce4D+N*`>{=$pcV&8MOerj=n;`0VDa4%SiqF!tDxj z&A@rf8ZUii;|H>lF^{qIr#wcu#3$N$sr!39Vz+wYca(A{Zkm;8`1SASA%WzYbl{ok zprhpaQXX_GeA=vQ^BVXM%#eQ!QpPZwWd-~<=}^zVK5L&|YO7gYQ!|bcjV^wW0=N9E(sMulg2Hc) z7kdJXddeWw;ps!}3E5A>nWjz2hrT*n^G-MA?J&>&ybsk}DjQ9%hwx<=L5Wl~rOE;4 znhuD?I)&JfUf4@*dQbx1yY<>5dv4aky%~9UHjTVx&}6jXS>EmA7DLgRzGpk}D*|oL z4p?%ul(fy8i|t7nalDg_xq(yGLl*KW8xtuukU*VWbk3n8A`xctUR%EEK-z)hxpLkk`-b>X;3xfh(xlucI|Y zlOXELGG1Cx7T~kGd^0)Uf6vsC>|$R1xu?}lC&zkjLk|e;a)=z(W>LtuZ);nTeUCT# zpT%ZL!;Qk4EHQz@MqLi6559kUxh8&j6TkKeG}yDgGDKc`92d<=+q`z3!zAT!-!``$ z(yEb-{e}F=`^%SLYMo-_i9$oztcRz-1@dhqg-=5ui}MYcH?oVn7Auo9-C8;@p+QHK z@-!^NT$z7vGsjtpP`Pc#0OmO`pv}IkX@>lfJ#uYGjZuwc&86Kogq4Hh^TL|j!>(+X zbFOXkhLYQVO*4l3vD6$Ba%p&=Ez(VE%KXJDG|Vb&VD~!C#SX^`2A;v1iSaak&bUKx zwa{ZQlwR`HBNvyMlrTkcC_~b!F0=}LR7$rFCzi8Fkw@~x!ysmShjIiqfj4%DD$7jB zr60mDjnaAJC1L#~>RkbD| zw<)-Vox0OiI$>wWN1@YkdTM_a=xx($A$U&$Mw+OqknMFKsp0GM30hUPDPUclUIf;O>Zt zF>^5Sk~IK00ZP(LW6C6m2%J{|%CSL=qdfbsX}hdxTTZ-_X97BHAR;r+SSD4TT?;4{ zB){w=P0IPV@~$ifwJu!xQl_+VE`6i+w0PJmQIY-N4Ig>d#upPGgfcpoNt}Q$oI}^Q z&ueE3dv8DQuF5orzi@Noi;F-7k$o5b-Aeug_Qz-2TkfZxSqm9$Zv%Q7L4UBG41WH6 z#1xAh@W%?`JUSLYBU^87X6AB%q%_VIFR(_k-S<#WccbX8Mb)e&WEDi%Dq%|$Ia0HXeQ>T*e+lrg14FV?rNSKQ6^JqiWqO3b0C2o zPW$cT7fQo3z#@|T2!C7H zx>Nqz?A8r0<*I*Q8@hj=Ro|qD@7?Zn%du-hCNH#R6E-dE4UdjQ~^EiT_W7@B6U=w5 zF~4zj1QWSb%t#6_N1p1HBD4cd-rHCB2(5V15fhq5RIS`$tfq}mUfm0X@5OZ3x5FjH zP(;Vu`_Q&nS0+7e)4pAH0#CAGPOV}d7OqYGmrd<7phMl!z~T514*jbt_( z!||<>lW#C=RCs_i%vpqGHaa%Mu~18C4p*hqCz^fa{496s(@Evj1Wg5T*c_E_kodFCE-`Y>FvP z+*v11UTw?6u!jAr$X&JlGSN-~+8+NAD^D@QE&9v!48_71bq{Ul;PO*c6wpCD7&&jV zU}b~yfj!Ek%Rb+IZFJ*U@2H-FoHI@pX)5yg0MHuuc^ zun2~&nb&eYO>QR*lG6u6uny^8aG!0zX@y4H>rXRUSfjnXmL%>AG7@>6yhTM?O>f+& zxl}D0jVpfTH*p3LD3}a6$9g1NtwxkX>W4CK>uuQI!_y>xpfk4R93@ZmB-!UcEkz|X zHD?<(D5e@>;!eAWNT)#4xuV-s5s-kC`qT%B(V?9F_M{80S1SCn%BWzX#y^4Agd2^m z9jS#TxH@uv(h$Y~gcJcrjUDRz)La$tRJ>$59~cL1^BTV5mg_+ENc27E<*D5g23MAM z6${u1L=J4wcTZLIIG!wKa^UnxJDqesNLD-}s{&+qcKpoGQM_F^PbK+r^+&WF3GQX3Nf}Z?#4xwp^6g4mc z3b|1S&~vl&`^X?>+!_7Vk7nc>Z{eSW3V$nhwAaf4MPq~F72SmOeOoEee{(XyrX!qa zVt;ItXoX5%`xciu`#JEvwfjF{xhLx%=ev?Uau|7WROG!Xn4fWF@=BB9azr%y0M`~V zJadv4B;*&xd2j<#2dBlBcb1z@#aeM68S4%59PnsIc}GlkMu|SgLtiwBn7Wzt*u5^C zVnex(b>x^!p?+I;`T=kq7MUrM8Ds|K3W^d7Og(sMMQO6s-WS6B@GA`%NdeEN0t9a` ze8Z~XOOwcQfB&^Xnu=+($sL$MH+VCn^JD^Zv?5e7$`YJcz9#q;{v#6b%adbz-cO!9 zG;~RKn_b{<&#xR{7xSgm#Pkk1j?wr)EdqayauUU6xm)i~H;|jChEW6IBIUaF2H#Lw z7T>QWcGXr#F@Dy0^njy3#@KAC6H|feZpSo8<_$C!)25(uqb7$tNCnAs?#&J{Z8zn; z4PKxvBILA{jfBUj7&^uX4(G|Iw0mP{-1x4otlk+-*n+8|5 z7X09jecj79EylvVz*P9)d&u8HC*EncJ3g#Acc*2WO5tx`=i`yA-<;9c*F1%qt#lmkqANbx@@fp_#@YoO?9JMZ&sn#&5A$9Q@ZOd_ zdo$zjVAO+=Tqd`uJpyMNYd71g#KM^2RmFw+Qh1}fAo{jmVum_}GRP&hmt^!C?FD+- zG4_*25(goXw$Wg{V4ECWcUo~&S+zDCOPDd>aKgx!@@CKY7S~%Moh8MaSP1#a#r{4 zDhE@K5Dd!#D=|lPM~}x8Zp%e>V1}mac?7Oz_}5l3GNYmVpi5C+tURF4-pN;vi!aXg4Lon&)MH8-HB78 z9@Q=yNe-{huEiVXW{TvIA5Pq*T^lS+0oYH#xe<$j=YKA1E5oM{@;YlBu#vZINs5ne zl>In)YvV&v=^8fu`QE2FJCVN?Dc`2N&P0&Dm<6pBw@w1Q(G;sbPL9`Fgl|4+yd|^i zY*Oc-(pBZ>}&%HeNPPQpz`N*(Cd)n(Lh41F3 za#5TVig(`{yQv*57U22t6JVzf?J)xgEeEVWN+x3G+i6vy(x-Cedc%|%qmwmf$V+UT#%owChw zDNntTOEE`hSo{cF__cnj6h`9)OT6$j^X<(`Y8PBu1`LOA`k3Z8z&x+50bJ&#q$=x} zz`WUYj}(!SJ}WFJmsJWJB`tcP%-hjkc!~cf0EJ?@r*m2wJ{bwaKKC53=^Cy_=@v{Q#wd|FH>S z8LkIW3GAF6G~n3}{R2LnCimvwNQpvGN6^|$-jCqC*8qh@*!-+qJ#}sK+C#I0nu=3~ zjc}S-!JDznt|JWJ$1cDJkhDtx=EStfP(_1dgYw?o%+JPT zoZ$iD)@0*Zwe_dn7LI+ixZ(>O%rC(8oQhX|_c|2r+IF}Z{~+-wO{O7@2Vi{9-;tO{Cw&DB zTS9HHb&c?uyaiHZX4(MD-&wyW?5!h3@ao2|4k!nL`4Qx0ZPHiXe`k}w?pwMC&DmWK zYhkgr?1-88V!c^RjZ0FTg@sG||KRzd1 zu{3>^^bJ3_9+RE9@0g))+Gg34tDFV-3A$JL%+S7tJ6ND?k3430~NkvEQ;Y zpID_jaVX&;ZVOij%Z_C0wDjCTHmuDtK+pf3$`ZHcT2YH(Q`QsOTt3QJapbTd!b81!dpl|wc91*JD{A9OjN~N+w{l)a z2}ZvY2Aw(C{#Oup4D^nkDBUygJeCQVz*YzWxyE{u34|!-?L_Qi6Lj+r5V??u;7HeL z*6yQ0G1eOOATvKDFUU*q+JSTJ`<|0){68%*IA7%3VZGT}_a#FE7>FNfYlf5lg&>dC&s>kV1JKk8x-Qa&&IGB>7&1 z>j>Kh5+Agb(M#3g8Ekk^+Y;-{eBi1u#}{|l&x6z?(1^s@N~4HWT3 zI~cVK&9kD`#tP6@x7Oa(3!)T;Hrs+R&pM90uZzm0<~&gA0fw|N`2@eAXVfAL(nwL% z0#G&i0f46HFVx2h|6#QV0%oYYP!jdITK^E|NuAd~q1r?Jn1A%d>s||G$I3IY-J&8< zW}7Udy6d~e6(q6a#YX==#I#`Nz|RXUO4c_tC5qJw9}8&e?9DE zO(KqNYu1Z3E}s4LS$6ssnUM)57g2oMrSy1ygUxM zrygjUke`eWxvmgQ;e&uk^oeX5-ZX8ziB9gW&((?SNN!aKK#ftJHMDxFKADe̒i zw{s)&ccjV{A7pZHY&=N6c`^@Hc0iiv#}gH;qR|ze zSPfw)W=o4xmlnFv-4YqipPC$##R%;L#exU)Lq>|$U7@!OlNQ3@lI4gisun_2X3$Lx zCJH;&o`)I6if?zhFqE~GKfLpoytF0&_o4MXGnjOgzv3h)$bRqgZ92nLS>QWSB5s}S zgb|))XZn16gtQvz;N9vPcPH%+AbXdZ>=!$^^r-b6K)d{x$D>6Fp7k)`=&3YaOlxDW z=76Y!ya*x=;DSZFFR^t1@Vs&6h05aCgA@S#n~v?k^U;o5DiMM5{wSZ|7zKQvXr+)G zvm9e4P917Gywhpd{UD#T<2n?iVT0|7)$1zVsrYzvy95lmD#%K&tb7$^{W3n9yZdx1 z(N=&thg+WgGa~E}<4^W~8_Yi>Y4#V42$J%!%K-w*nrTV3Ez9F@|`s_%|Gjn97REC3TUk&BigPHz76s}0a{2+On3 za2l06MO@ZtD1MEV0+p9Ia?}qf?mia znn$A8YBjFs?%4s@SM_szLGRjjCuSpPV1c?l`-NfQwZZizrNyTEHMyN= zGev7cA#A-kl+$|wA3oc44)Tn=*pv2R^TF7JDi;`=|6z-#9)-3;3*Ki65asjWY&6y$ z%a2_3FN||GaL{Qo6vX2IDyV#fykp|y8R2T=Qc4QIwq9xw>!8kA8Y15H#tIL!IuD{A zL3y2u--$en`m2h^FFA4q;Q0>dogWt*_1VMc{GD>9-m~D5i-*7Vd2CbmZfThuGIt9U zquzRlzxUm*4|MZh1dtB-idqc;m8`S(6h))vjsp+)ku~7UvJy>65mmk)3{R)o|CSBx zd(!y7knb{H)t&*^j5*fwdd&B41)u~K>uBq#tM;KWaV+bQX@r$&!NJUhJZiPQV;ac#W^lkMo7lXLFG%uU9wP|!)|N8*JaDBAM~^RNTx$5R40zu8zJ%hQt1bZghbI87oy*_ zY38D6qR7sGKIBFu>Dr6IWN)AS`mJl~q0As=#mDjvL!FO-L*f&Mn)>G>s*9(p<}F*# zoE~r%7&|Z`6t>GdD0U;@3Ewedyq0d&T)zf91in}&BzBQCY?fd+t`A4$4ci@V6Fb=> z9ZFGI?^+at92zLEJ%S}q7)lM8p`bVaw8F#oN<=r?BK2xGQ3hH^#gXVMHwdd2bE>L! zMqsx@gj83~&Mmr1r!@R|_;*;_wqXRZ646&-mG;bbsMo*HG)!Y|y6Y(S&9v0Jfew=F z$HZ&cml>lO&&8)?1_9oW5ZoB2? zly-I?`Cbwxf3;ZRufz&1IVx{XN>VlNm<0J=NpQr1;VrVe~8m04e$^BG=^MRt9J}U%=fh4N*?kg^dbBUg%4RsGU=l zv^0}Owzy?SaG5$;7EO`eNtPJ+MC4}gM*AJ;vejv%*8yYH-PtcKa*I6{YN$cX$oGXM zik`80%{zq6fRX9ywBJ6zyqURgC%_^$v(M(KEej;PKLOCBunUl{~+T(nfu7p0isZt5$H{A+QMSRyT-;=&w1Luld zAQz&GL{H~?)mY}33Ex!b0NBZ}T#@R12Yh|bnJe4u>&dpONV*wDnPqVU-<|Cq6};SX zAaPne97K3l8Ss1;-c=M^K)N%x zkLLF|b|E-6ln7&j(=UD$o<^}3?pj6qfKQeU0%HKiJ z7u2TA#We)yv7W!`G{WB(b`e7lU;_8PeS~oNz7sw`K9d2%h8mpGK@_k4QG>?yrqL$a zV}XdhDURwjqiOscBEFNT7>12mylMUQVs=L76VoBsnsVu&Ou6t^)~Rgan=3BC@3ayr z<54HfA&I(p|MPHeL#}4x$XjIW{N;jFxskVe+)A*fnP`33-jQ=TVQatxB z>^_GXk7y65qyHuC#1td-wy#sCyIS0Xk4h&aPzcLwL5DXA+7(xQ4;jX50aQSd_TdAo z5Q$dINCEcg_sb}TYcQ`4AE$gokgwZ!LG{rQlMuLgy%m3XbY?M7cpfg!M@l)7RN$e@ zy*m}0b2eas8{ZX0^lRaqm{Il5!V%O6lC$v|ea4x+xy2W#t5dCAf~NLMv5Xk0H_a6T zXrgdPt$G4QMj|=~&#J|}qkdK@k8LcTb&0r`dyo?Fx}+5ndq1%GTWGy5GMEP~xo*J&-%@m;>!0t#qh54csiIF@?&u!J+ zHaKZYmt*vtUTrKUN=CsL}P2KC0jmLYxV7DJ`j#*(oy^+Wn{Z~4s-RO6qq%I%;bzU z=+9HuSka}+WT3Ij-PKg|S4`-D0Hf<&u?La}^o1vQF8u2K`&zjUGG2i%s0cv0KJlT9 zPE4{w8s?s{|FuWAK}&?HPkR1SI9|(pWV-D+NRjQ%9=0kj-fX1r?3-?NuTHno7(JVY zB9vVDcS{w6;P*)lNW1JRrM-2?z)c)#&FL;WaF6Nw0b#4J!}>}+xoTE_F0vnY!btU> zkG~z`-mX5>&YhW=svPtix~-gr|LGaFnw?pqsD_k{FB?oRunF)@$Lt!OR;y8XqGz=2 zoHiuGJJ)Ev#0L{_(*>qZcxU!y=6@%oWUXZsSl47dgstd6lXL!{P@1~H>ran`tt|J1 z9!OyL2CIYTcMk$#AJhZl+W&e2e-jn8RB5Pl@2bXTdy*UBN6Sc>Akgqh5ZOl&AYf>T z#DFhKPC@Jy)^iV`4KR6iW}@|GTP2_+X0SGWc9!i0tz8-WJ;75l3E~iW0L`I4)8by< z0?tJ)E={NDjJL`nkdG&yZM^R=_x1SlRkFVd`sB`5cF$4!6mArK*^=dBh0H6R2wIFq z1h%)FruZY2RP~v1$d9QMHp-f!8%!ui)z4Qu(!9{;AL}nXRikHFtp~O>`Tz=V>&Z-l z;c98hHUm6$i7qnseRa(dU-q;(5PIHs?0q4#bHnNpIp0)e0nNOVO%OV(njee(I+D6E zxlKt;o}GkDEhXoTauodc_w>y?bhr6331+Um9vqjvabG$L--yd3k0Pw_Cvf2izq>}_ zbz{oz2!x2mN+X8k(4L9TVsft1MKQeSc}M@HDb>SM6&IpUR~arAK_B(=Viy-xi#nZv z-;jm}156GN+LJsZ=u_U0KLqTs!#i>PZ&%;U?C1H%`do=#T{_|O<%kBpcyp;}&_|x# zDzz^{9=Y>W1in#aj zXSd#xGKKnCIY+v6M~2#w$)4gvQoa^b05ysbEKZimUcOpAcK;km6scUN`*W?{*)jOP zNY3UQmyHcc`~8*glg)n*PCnl%!TyEnf!KU+^|h`9k}hyINnj20&CNRl8*7f)#l8Ox zy*)B{j31BG6pn#}doNaJpB?iJ%%ue*m3;%nGH8YQHlX4NCZn_0Cz`zs9>0%&n?LFt zS_>Guy?@BQlZLwD$niJv*^_AA)E2QqY}2%dJ`Dk=6%HdliS}#!F3nni_?`N$sSQT> zu~?KcY(=rBgyojxg}JVKb-yS<+pygQdk{AW;v0WMP2JzB`Cl!GZIw%5?aF=`M^q3` zx+^t7%qg60lPSNIu}6V}p=uLY>_p8;&>GOFE*gg*&Xq#%3_UmfPXcX+Ik9h(q0zc< z>AIBGQRsFYA8WtJ{^CvVC*9kRD^eJ&wAS(w51K!;?PEoc#HwfM`6IZwMxp(b)s0z; zchuW@E5#)mRZCCu@)n|Z5r`<=<3k%(3&UwHV9V*ogI< z%slP&_<|rc^TziMc0dM@@@0IqEc+?voqtSzlAm5KOji#|w z#Cr#ifW9|Yo9wpS-Ze6RU4SRuX&G`^39E$tiD}AU`@^ zu3)&Wz7ZQM?_K{r)N#Hv3nVJU7#z&T7x%FoU)7A>^`I zrfJx^EhN4kY+z~z%Psy(Xv1^=ru;XiB|~4KAN^SWz5A4E&;FnRKe+NEZF{1R1;~$v zJBqv#`F!uZ+PgfB2_LQ&hQCw_^#m(}8RJVP7_? zWN>KOAsq4q{65pdjk>$MgV%9k#IKpOd_;J_nW79@btk^pRt$)nzMR~@lv#zH9TXA6 zDVJT0&OkbI_jjrKN?J97nNtoegmu)2IXi)Nb#)srb>M3vRz48>N9Q!_V~YmBM(<>Y zDgoY`lplh>;5Dx}+n;<_zo*`EuVRY>gFJ4y{k?E_0cO|wwW~u` z8gvAmPY?VRvr4Bycu(h@rp2(x6w?vNLmx+B8 z38EDBIdi_>VgAiE24iaOm@erKithAiWlE@CLgeV+qKp#j#F2TY{$%SzHI!718-OOl)>r)AjQlnqpQ3fP5>p|(C8(S7Ri85KN_a?1 zvm5w}^$r;{Qj&~PpmaT7?@Yzo5X^ZA!_8ap0gI9iwKWsRno(}yf+@)D*%1>;c z*hp274YiBW)C+rq)Oxdp64*2hV|M9TD zUS8W2SVSvhyy zu}7STg3KQ!M_9XwJFEFnS`W^3jFIde5O*VHC`8OGF9v^~{%naFcnT}@Qw<}C0`(#u z>O-r%XtsE?i#6E<+-wXogtD?-`Yj%F3QcV+9JYD&2nm6pU$56<@iRlNYut$39faRs zg>F^ytqg-qRAUxyNoB#*UxuESwt^&=SH0J5(-Tgj8z2)_)T5XelK<$TR$(xS*t-$l z#ms4ws8#BW`g`CflFw6*0*o#5Gy9QlkMqLx@^{91+oRM00il3=AscpS1-z8lBwm>! zr$=)(JF_G}_tnj|``(yeUNu1`<&8UreLDacqW_C;;@aY%@pgBVVD96CkHJ$>k4xsJ zvBkPRg@bAn@4V4N`LV5vd&S0Q$_W%HsT*s&fJ?gl5!4~*CIw`xB1}tliw4AVstqb zP0p8{mTeH$VYu@@1x3B{(Vm0Xb9vArk|)0R_g)JSg_z_SV^WPJC=fYM6t=XPXpMF8 z1`vXV~->`rRIPCm3tt$AkRj z9Cu3tw~u4DJtfP18eHk7i^RSBCr$*s>GqJn_*YdL^p`@6qk5QI3sz$3qk&Ku)X9Kx%EYA_X&CVvuJ+Ag?o0xHaX|^GknoB4k(QNC$ zqNQq!JT2t3JH|FpAm|m$yeKRAb?~H+r06KsYDOITp&}Uo_ytpua#UCN3zF5K?|L*H za%oTP29{J#djErLWvp&?dj(MzgD&r&(7@3nz-9Dg71%x~MI9k2zuME2yG z7!uB;6zS#)Hkwj{u7C2zmEq}@#z`DpkFy=jpDd>zp7cX2)lfT zj`@;x%N{cLIWvzG?`9L!eq_N3F;JqBUIEk5MBf(&^6_!NevnB-D<%G!BHFW#{Me4y zjy6iVTRZ*nc8dLWx7rAe^?Zb|Sn{~gx+o|jrzJT6&CLpDnk$_XiLw-pKw2-{@?J9Texl zO|N|=_czBDg(W`pI0m~|g4frI-e1E?|A$Vl5gz;Skhf1UoDxQR@E7;a@^yEzfj0s@ z1vPN%@`jJGEXuu%G`e*9JNM(a#+A7WIisKRXP2##oEKgMM21$kDQt3^^pJ11GU7JN z_gE^lwJJiQ4(E8c|G99g+CRS(^PggSj1LZGr^vF%Dh7^1@ogl_w$r)w#j%c{)|Qy; zeW+ERoP)aXQU76!#Y*C-RCW}$8K4$~?JSRY?A-4psNKjRR_aTJ@Ls3ir`Ya;99Nux&K)-YBk?&wW_RZf6;^p^B zPkp2wq!~m*5^GK)&=(SB_Z;Sd{&#Ks(3rApi?7P$LtZJ)g*e#+g%3@OPBWp(vJ5lf z_^66*cE|ppn<+6?;=pW7F=|#abuewBKLb^>TIVwNiE* zJbvesTG!2P(E-e^z}3GlG%>`aW0KPxYT%&1gSC&8Kr((3b4gY{=QJz&v_5YyKd%R)!C=98;h#& zT?3(h`?OIX_tln|nq9m*Q}2(}spa{djT!Fj)EV}Q14pTmBLnrWp8okjcOo<3B;}-= z0b;YD2$5w_JiBSt`OW`$@{A6j=5IS{6)B3DRU4Oucb$^ef;n=nwEGKQwe_NIn=l_T z?w#NV!Rq#C>;#f zT{?vE1Tpl-!=t4}sAO6ejNz;PpZ*$fOHi3C+>FgrW zS%_E=0DTEvbnx=s&SlsFVb+OIrlO#h!RlZ*G=@Y)(?R^nv8NE8l<3~1el>Zqb%twE zvsRH!lam_kWO&q+#XTW=9CtQ9%=MXJgchGZp zBYH`_pH62pq=rAkO_tjKwuPv5eV0L2!qjY#MDKVQ#ZDv5ph>rUzKNDx@8%y@9daR` zEFz5wYj5K1debZ@rVmno`+-guNVgn>@`CY0C3d>?7u=KUzxWRe0$xYt~U zpkNxJDlrhIYi;3((K(pikwjrf!5UH`-)faN&AV`fdnQ0puEd_9nR`erlY$m6m4OM1 ze5rs*uoC104Ffuat$%?NdzWkWPce2b_I$-m*{H?#+M>ow60s~wq-l=ca zMw(!=JtL{=J)eic^&3FtH_217?%)&f;rU6h#Bv3!HehPP|``>PzmgOK)mJ@gl??Y9!Xy;iLavS_VmI zH%iOY-;OKd9-F$M=5J;QUoUZIK>rU(=l;+1|Nnnd?d7AaS6)RqtX?XwUdmEA%wbC^ zO3|5|MysUJURuMjEveMYF<#|til`ix5OWx%hOjwq$V{efHs-LCjm>xO%l9AHFVDwg z_xt^Jzum46v+vC7omn>j3{G=<#;*YosG*~A`2>--C`q&Zxa{}3QV{$X=rV!Z)vS@Z zuMD)>B!0#6nM+fjcV+6AV32fB`GAViI5n1j6{T<7uRk@Dhb83Jn&+iB+;VJgJ<+owL@D4FLro!C!V8bAPwheetxFLS=FOn z6`6J9eYgFAN6nziiJv6z$bExQijMc&)k`%G;+1TD;^aWDW zN03-rZW#l?ismqOu=n!|zjqyYiPH+z{2^avFFUt6J#c!1N=ni;JfD|TM?MsBVaz2b zPgi4nGtkpSw2!`3yy7wd@5A@@EOl_5T;MnADz{Zx{JL}KNoER;cdI=KYUeQV+cC@e z3Wt$e=E${(7q1#??p-#9_!!us7{3yJi=lg6=cA!Fn(Ol2QZt8U%xC6FwydgZTT_G&q{LC~tVsmon*T1#% z@MN&^N6+)3OV?{Y4f$|V%GE_cqp}{wtM0nfj1Ta=5%1cwHlCTAqG&H3y}u*NT2_GW zwfIWb@3+GQrSwz+AzB4%e|07R4(|TM4G&KF+w!0@dQzorn-^Q!iu_$kE2qBpwtFOG zCK_>nW=Ctnxtnp_h0=Q!C|V};MzS;%0*mVcTr(h0-8=msR#Hy8@f<32AztA#oNVY~ za;8ZhNHfDG)Se>obe?vV?ZWx3{-Kdwyj)(!YFUS5GU#~8Tr!1Kj#^!S03qF9rbka~ z&(;gA&JS4lTz}Fs5pW%H)tB&QJR@!glJFNO#y!u$$CLd%4}x9fZ6MYp)g%M9K!m&H zGp2WEBWFkYS@#Fe;l^qIXZ)w|$>x?{+|7D2^_sD*gb~<5>>nR#+P!EuF_MR(kTrf? zWRo#T_+v9OiV4SHqF6S}`*wi@M3i%8?#C@B{f%cWt0SrwE`SJA#QE9*w=A~Yldy+( zwU_n2!^pYv@E`2&=Aq?y>Q&IYh~bxr+cQR9s1r$jc6LV0r46dk33ExeQ&5Fc(C!1e zmri0-Ag0K#{tTr)Q1qE258QTr$a~1Snkr~FlDxaz5|REXml$Cg5I)4u1ia6aO z))rcql87-S)~Pe%_f+Ep^Drgr*@{;YbZ?1Ozm?5Z#iG2Kuw?KunG9T5u^_wsLzsj< z$NFKuwzxMTdub&kad8oD3;hE+1;XP(t6CjiAI|n6+6F@HUAAZ76A~t%q-_PK5 zdzIB()n}}^vnU5t5p1=S!d)l6T@3vC&if&h*22AfD;eiYdp4Gi@lPA@O^58}49Qgi z`0XY22i2!4<^?~tOz+sEUf3n~)(E92N+X)x!12QOvAV{$cacdqOq)1014eBm*`<9~ z?EU~KZ=RL@K9_C_+dH|r&0ERU{Xba5Ps2u4+Em)l`5lqt{{%#Fu6SdZ@Q9mHsu$jo znjSwiYVHlLX?8&LK8ezYQyd}AP*!KW?h_R}ImR1*lV{+8oD=ZT)=SSpc9-yzsnpi! zJ064^DJ0yUlE8*#Kf9-AJkn^K&&}&$z6IrKY|Uv9>O@BMY1*hm5$xc~$R%I5cc5%N zzjBIf`*PDhO)d!eleqPsCwZeaoO*a8hAMFDHi6i}lO(PVq*+%>7+;V7WYzyjkS3#( z_{v{-hWfzzjPNPsaJ|E1^imqAxqqq#FLGOQ<>4C~B|VO3-NYPIFd;%NBNBS2Ozh+- zh9^b9=9!E};F+m==g!D|X1&)=_?dw_#oAA$4GyFp6uk9n<+W_{y_Yo#-qiRp(4kUH z;pL|hqS}{UdsV*H!&ZXyAxR3ij}kI_W|v!~-G2$lhu%j5Gs*j+aW6w!elnYyoP^}T z{ z5qk9yUir*H{ID@>3i@~58!u`JD4cBn<{)pc!2WcoQrBW|x~7h@!w)^HYkPOB{s%8T zoW(WpoYij`9A(J>PH!C*7kKxhq6J5qVq2$Hnox>iHos6Ui;9BX>#Ad=;nWXBt!q*^ z#!K=?7ttP~TJ`Wqam}E&7)afBMs`|9F6m!D?BUIy(@r1B z2a=y3#SO&{9#1=(hPe3}v^ezOC0JGK$eu-#f-(gN{Sw@<#x#jE-rw+0sSEct#4cBH zPoMwd(^o}N*{J{|_q(s8JdOPKbgy(T_b3B%>!5J71E9UVDud)RU!A zVaZ}QrPN*I@_NlEtn;LFVaqi6r>YJ6Lu=HxZ1YDiO%7x$CJ5h=j@gA~;5P*H0%(s* z4`NQI&Nzbh^wC$7A)p?B`${F6?w_G%c$jL`k2#Owh`4gn%2(s_%G7U0l0wk+!u7C2 z`??}T27MN=7}~dd9rj;FI=lo)%s++X$h0>xoMQ#mN9lSm;{$Zvkw$t}*9z1V=);MN zjCFfbkUKsM9tPt&uDPMN?O#A_AjM8)9NyOp>N^F94QH0z>ao$10ykc4Vl&8gWVl?}<3>uHlLI z&pcb~#E+_xcfJXUnx5L_jJS5sxsTwXFHFcNK){UqGIA>)s!SmNcPvn)mB$I=md;~? z0+G+uf&s(YGJT0RF_M=oQ75!oBc9vjfaL2y|E|Z;lAtn4HJpdSqZ^olx_Fh7*-VK4 zLocdj%|=loSZkXaMN8%7? zOpq=i2hy`PhFj*y5yrscmkzg*jt&ik^pw5vMvkyj3I2ID5JR0J5W zB8~r?BS-AWi|mkGYiRs0UDcQ)Gp)ZqU^wJT-N$!2f6dq^n(s69kd~5a?{%n3=_&|& zZ~~4lneb8ahj*tK!1I$|?Lg!==9a$>H0EsQrvF;{Km2v40r9Rm84}9*Xm+uMtQFe& zt31`{JW;I0EbB4Zh87ePacJ{FE5+3NG~lk-oxE-nakKHC^4)1ks5`qgx>f{upvp@i zDW~lJjOAqA=#|hBchBW^?0*;gP+}y=g9rB$Rc&b-ZOj(lD7Nhg_T;WteICoOZR7sW zBT}Hu>#&AWeP9ul+$x*&2Zi}Zmx6R8$(7{AUE40XS!8@cT(z475P-vVUbGjh+s;|E_eD zu(n{R9(RqPBhJ_EKhY-?}Jv4L9Gs>Bt*fL#Uk4X|4AU|pruyTF=zVv2)(SaQKH z;9GV+5Vu-m))*7!tI_Eiv>=FsmIGyW%9$YBpQe=NjxjgO?9{?LCXoXQ=13HfLi zNzHNflO7>ir&C7zEK@GoZD(u)J;_fJzxrwU4iL^g16RFAQA z50pil3RbMvPoejv4`T6&xRQ^R;QQH`AO9*zJ$>gAP!djc>HYbqY5mgBX+MMX+6wLE zzB`n!K3!1bb77SmMrQq(n3CGal;U7!hp#d;g!RsDpK8J7Jt8bn#eyM1l@DwrWvD>z zzLC3Wah zJM!TT_(s56+10zqT!W9?<}P0e8i$$h(;y;3i{tTU+B$r~m3ev^3ph7ubElI4y~!*= zvQ3FicH+%xQXQc>${l(+KkSY9S<679qdX$sW}o<9Yk{dp>Y!(fe;K4yIF;~!_^WYC zHWie`-KXT1IONa3+2h3f2zVz6{yZ7}*<}bueYQ}X8Wk0#d;&Od4YsiVH{kA7B+zbM>-H(i6tP2l#8TNg?yAJl`+sJ2 z?B}I-d~JTvYSTuEyndU}OMc49&%;^6N+ui_-(EuE#KD^!awBwO`dWv#3L(rfz~0+s zk=?Yq_bi1DRQZao+7L$DEA7km3TJ3zj#bU(`4yCa`j~LPlq(DZ1fYbVau+8EF2)WQ z0F3Rt;tBt{mxv;`IPH6F5CfAswVBq|TF01NN#9A%UIN z8K(}!$YH#HoiW4SA2-@F5{-g9ANICr@myVr5Tx_%lu8|0f1X|rG#TkH;MeEYq1D`l zm_*6rWN#8_x_PFFC(wjN;?<`aL^}+NmSX*p=9ld1Eib0EDla?Q=HfN$VvxIEuh73g zgdL+aXQ4}g66H1eh@?#O9f&+H%L#P6wW=pGsQxFyX&}1GX{3TgLjMsSWCiaoK=|u8 znJ-kd9cfp$++EuLn{2ylOxq_7t*ec)SWwj-tN86-2xRZ$8e+rwe}a{}LIrgfMxX;J zCEAG3`eVX}M$CqM16!mYpa+5P^D@owXL!P`EiztK)7%QMCt?L3^#{f;q8~yvi)kz7 zzlAo#PD*3oHIA$k3IGi_i^Jzs*a7_jyJWOGlw0lD zo2QwptBBQ`^MU;GqgIIB^H)X|I4-ZC2X=+>7p-XuA;4xMVd3Nk4eF)@JUMT#YO%Q9 z?IY#$5ptsjyXpG{oiWKD)qKgff+TTB7@Fdq>%e>s7r81M0o*En;(GDuR*v5Q-jb3k z#n4jYp)tJP5y)|6lPC44<~&=IZP7V817O_ZY0YHCcn_s@G}paUYdlg*Q-~v3k?35& zjQ>DKZm8S9;WX=v>LTL3u(#ccT|EbRU&dMH5zfoQSY8A5P$a4L3XXdU1x3Cphd!wK zl$@A6?A9dRO)@UQClBuz+LZo5odCTH-c&?ylwc3G0p71)sY;Foob?quB+Aw5+N%rFI zGaFw|<(}(F2n4CpxjhY%ewt4xNRww7xmPIZE$)RJVZ6lh{o0{1G7s3XG747?>w)SpkG?_~{ed?7~jgUKy1vMjxUf zp5OMS`-EnYBpH+-Q4gT5O1uHt^KnOc+QkpXWni0fSVkVY0Ca=3 zCyj(sS8C^2%9Ehg{7`Di`niL(&pklmb%%NDXuCmw=es?mZ6w1rNP@9JL*o?d2=};m zbB$WKNVk<4sBx{NOWA&mkdlodR4*Z`_OpzlT@I2ZUMxE=sP% z%F<0s*LY-}DI{UyH&Bq@%!KDx4;teJSKl zGPX3baNF{pMVab+8`u%19SY<^WJ>>ZN@6c;hCGTS3g>~sZzRL+SK4}#``9s#Hr zDmT#5Uq`Iq@NXugyWS{k4e{U9rosxr-ww>^#DyE3PuJ70~W;BXCPl7?+Oc+QK!EfwSOq7db>!9NTLou-3w~Rhxs<(`9rNdy3?;V)!c5Y5n5qK?714Bz90{rQ zt%bhWE9|B56Qn&f!^qpeqVP2B8UNuOuzf>S7&NrP)iEag8FKh#hRwig;%1T`DOT3E zq7|ETgA}?vj4U88-*V<5Dz}q2;uhS95DD73=-dbFK@H+mJ&m3?!~E75fS%9`oz?55 zs4G}4M=g3A`4=CeE1=}ST(O%s&c$)MF~HE4L3PRjp+hDr`h}+ogu89i%E;>j7`b+$ z#|_}AIDeG#H98-u6^Arq{2%vp(;}ZgC`nAVy9%!+I?x05T3>Ty>IjgcJ;EP-IZ4$6 zFO9tLoI9IrS4P{g?`o{<8Mji_gO@uALhyITemn!qn*Z9WmAQeU>C1M-8uVEMREFZ2xj#xpm!ZYNoeL0t|pLxu6A#Z#o6;l&$c!h0-vPc#!u zej54hPm^?Pzyn+PQ1kR|yWVEOhrxwPJbxySF15buHt07Rw{Z8`6TZe73tpHGW-${nr;4ozo6yo%CNA=)u34_H!7RA-Ez z*DcnD@04aIbbM1_3+zKQFe38CJsp|;43~?9!)B}1Y(+YH%Zljn%3i)Cg6+FV{j=*u z$M;VSA~W<}G zDn|cJkRXS35aj^_SDEXEPZgtB`I(d#Jt45YySg?AcHut4tznawa5GGZaYAaIja@rf zo{|^1bjc9^%5m~7mtEG(T13iQ-Q?8-rF`bLcGc0U!&W=j-UN&zWLsLm*JaOV%VLzX zHfqZ5;N$|TkzbNim6_L0o8?V^koPP1OW*t=Ux#7H`;_iG(TE*j3!?WGcF1OU>?;I@^o8-J?5m%MkBxn>1|j-%9jJ^iSybQtCeJtMHh?4c zh!yi4tx>XDHRHHZkkM{)nCc0KG2Ox^dq1MMw*|dm#-A3vT_pFTRKW+PjZVx&*y6_7 zDT+1Vv&{VeYvuuj3txT3sadTJu|Mdow+5o<4I;7{$j@6@?~ZHZY+C+>{ZeRMik+(= zT?z3NKd`yzhTj?6InhW@9v+RNxmSCgQ@VFTONeMRo|d$e8!vG|p;N}i<9TsG$AR45 zofk35*DUJ-EakVQvwFC&e8NaU-t=k?z{gE@$0goLCx^5erf#qFb#XlT<3?#gF%$St z;X>QG9r9+omRF7|Gj!kcJK)hfl(%!l{faGEFUBcf@`%1d;FERJ#=l- zZkXKgY`a^yQ;(7J?Mz(8?zJJ3t%XrdhSMevO)+`?0bjTciYx&r9pT zBFAUgeG3QWfu9$D;=en7cl@8#-&+!H_}A=>mwR$wSMJTMjknlfaC*REWl}dQ@{Qf^ zZB>=ZPkk_)a0L6l?4<={$btLE+@adk-G{SOCKmj1D+J%MYT8(feWQ~(pwmg9{KT+~ z3stM%aL4aeFYJ2+c2u7F?i|LN?NeN*2ELYWc!n!gJbXmtUD8AwI{p?utqxkH;KV1h zsm-DOmbR-4poirC?0WRC4Z{En=E=ga$#L{%NoBWxHYkOHz5%5H339+p;RVgb>TY@L zOQOZcoVjy>iTen}t+G7sJSWDOIId_YX4Y=VO+S^Nm7{$S_`S@`u{NVTq^H1P&f;1k z}uZL=;XyWfzSs&IxA_Z60M~yQuJv#kEDLF z7df@EA#QXOu;mpUs=+*tYEDLUK{VyhVY2AIsWCr3+yzd**%9bvw)VA=pM%!KWGuTN zd_9qFMqCveQv+;%r}42ed@HN*f)nu1o@kXVwc9ic-CB8wKRgI-T7sfb{Rb<2c=GvwOu6_-(wr>50u3IQD^s z;Ex$jFLn;*5^KX;9W;SSyer0+a+*49lA3rAnYp`lH|}Oz8Yy{U%hFHvvj0eTc`VTs z{Q2Yhrt@6scrG?WuaPTewemM{DUpjI(s8^w=GeA377$bJpEpz}0 z%IRbC3J-OKkh13*F=l!xy8%41u-Lmk193aNRUHq*Yre!8+BfpBvZ9@ID>{GqkBpr( zU07Ncq!&;%Sgir-e`O+Oy#3O9w7Z=6M>;BsB~Gv2>EN7!Z#yVCX_Ss$bkfUdGsdd; zmg{!#{p(}2rd(Sqn`RQ$7(gkRn^vxGiVL|tj7{k6_HbZGr?)&%wTRv($(e+pA|E)WCjC} zhNIc5QEvh~zrU*agNIw$0~ols_1e*!R!^&~NBgvKs~SnodgFiGDg=j*c4_=e5^8`6 zYL@*i9kwuTD18KpM)Y|#EKcWZ);Vj#K>x(Zdjg=lyboKl$1sR)FLrb*vAs}6ibPPZ zj@}#SVqGW;BhHdy+mm)6jHf4bZDn-3%I3cm`ySc7Xx?=XzXfTQmbZdp`wC7vri|pf zYDjU|-JH$8qt;_D3G>XknqC)Svegvbi#bGCtsHz$I(HbA5kX5^9a`%BwAB>6)Kg#> z7o2(xjDCn%&2RwC4()>pZ?Yu|J7vK+|5#&Ki^7rSZyTmlXqoBtlziz})I z9u>t7-2$#>`vXVpvJgTCS@T)Y2V#bg@IxR-zk!wm?B$#aTwPk`nusww7edyW?V_7U z5%S@D+g)WyO+Uv;c_`(3rYSBW_HF}m_?R7&Tp#f+tll~5uuDlaE% zn}*v6BMMVr)ZA9&0-lr{szR1S$yaYw>B5cwTC7GnIozba*_=KWv-}|YB`6I1xk0ye zqF()on|TBuJze`hw+2w~qh8iql3C>I)8G@w(erfQPu-a|JvIGs>Gfg5tp${tPTv`n zz96?-!T09TjEvQ?(CIcX(gc5%$3~r_qj*S1}u=?1707uYb?2T zp?AN-XN@T|UBw{W75@2QJyagbY6@I)%v5R19_T%pZ&|`@m2a4_=GphzcZ4<{swOt; z{xctMzcM+3sD3w}e}CRF3J~u2`p0}xFN*>QpOLYe$@8G00QBfh);7a{k$}jbk6FJA zaj%*rV&Z(22tJc|?|5&T#{K!`t79VEJIGn7SSQ?h+osI^RHL0>$kDUUMKB8IYI^Ze zFYcM}!097yvP$hUp~IT)idqdCl3NPWG!!~dENxCqLvtn~*zW!T@}|LPZDxL$d> zH1n#l%6m+R*9 zNzuG`qN1-Lj(Mcf+|!BtBW~m;i`$;Yzq>5(Zt}3QZ*7w3@uJ|0aV%@E;nLGuG%ZKx z^dVOYZ=RRwx#e)`cq!vT;4vx%AmXa z^ov@j7fffB;7HQW8SUwZ^5&-4ZjZpeIb2&jiyNL8nOJcDOq3u2G*F;!P)yvmTw89% ze_UyqSA9NBl-2;ppQsAZqO#a7J#a?@CA19QW#7=AAR1<=s=fI7Frg>w(;%JyNrDXA z>{!)Q)g=0;KB=(Gx=+M29h=saHT9987e5t!w~l9j=|8WDWki>apRqwQpOT zKs`Tw?m2aD9JX8B{P*&cC*u2W6kE}VZc6P`wCflA1JvQ5w@)s|RAnK&d{bmp?3a3sTLGp+)HmokuP>rkw#b6_m2;FH;2Fx& zJ1>kOK{75%Qep3XCa6N>B!VopYxQlO6iGw~q5nXYPjbzluiPZo{Ykc{_7o*S)wOp_ zBZnBG-E=~56xW9JvjmR?aSBN3D3#7(J zQ8`3=(_VntF?H;k)e5Z3?QMj!qudY-TC7Ww$nIpzv@3NQMq=IX} zUDH!i7U&ouh!hR^7H1&*`qyi#uC9NrkHz==oPII6ZYXE4>vfc;a^2Pz#omsuxnN|T zTWil75H5Fd25EL=V1gZ52?zKV+y!PQtfcx_(ZdZ(BOuN;uw{ynj| zsM}^s$re#*1kSvt8?yZ-QGG(*Ubdj89cAV%TOVDsyN8Eu6;nl?YvFT*WifCCqhg`f zCZ?>F6haKDUPX};N4Q-;(}?p?*j3Aq!d5wiJ;a*VY$ZK$lIiwE*3^43O*kPv0_#^v zk%sEdGV)%!qsXKA9cNq5N?NS7%wpFwCQjY1bC86sGN_8>v8E&|<8@6u1L8ozBHvAp zYM|%eVW#F)wS011Yf;&(8KQ)0mDj{TfaDz=Osks^#{+<%6ZZQ4@@Clw-nO)aXxL=q zec-BcPYJA!mDS`t`U(CPSxwTVS?~#*lC<`5Wzvs5)*_F>qqWMcOjqFmVbM=rfE|L*w(nE!KC=BVg{o^k zQ%tOOzdhVVuDdf>7C|XD6erkdnUWXiW2uP@A8BV#LM?dI&J*5W_NM%X`YL6NxFn#c zT-ng^`YzyWIdGR%2+wS7{@tug2e}=&I?aQ*f8RIgmg{d?RD-&>r}y;k-scf*Bc-i~ zV~OCD=)NaIFY9K@CX5bwUSr-$mVUq%YtL5DSNXj+T}hjBY;#M_&6e8%vkhwp2dp5Z zsxgDkmw6j;U?g( zBY*Qni*Hf06yE&Aurm)eG02*Oux+vufg`mykx@(Lph4#)??9%CZwM9!D&&M8&XZkf z@cb+>fYF)vo7shE^OHn77KPl2pz%*V=<4UwmE(!l3zi3lFeG))FR5p3YNiL?j+KU*%o(Srb8bucXE6odaT3oswQz*duEm-Px;qCNZD>JAYIhbHWX{gKLe9@JrF z0@w__2E*E?Qox-iLydF@dmP7xt>WbQQO!+FE9h2a6%h1+{uvMz0#%=88L(FgO~h7F zqsEe@Xeip>4kbTiK(^$1J|wG z9T)RaW9j_QMykP${NXpI`?;P4_@Y*!w)keip?&ChXpUV(p*#B1();@VaV3Kjc3Mz9 zx(RP#k3*sg_ePgyhYf?p#Qo%`1OHmDJ?Mr-pM~O%?s#nNuv=$_Q(Q>p2Dy2$H(!3Z z^|0CZb=x&Sl>>*U9eG5~EHK!rWb>?n&^M>GC?w2ur<&~c`kbyr6TIpwi0v7Pne?^1 z_5+dbU~P*YNX)Ij;&yA4>%u5T#j-)IEh`fE>J9<9CREJr!uuMho0$jA=G_ncigg)N z%y;S;#g5@bV>r~>;LL_Se8d(2EM*OR~yUvQCP*gZ%Edg}RpVKVE z{w54}oSGDE`(GKG0LRT-uCX;)>qE^3%DmPlSmJ)5Uo-weBSl*l51uO?Gz{^?rf49X zv4>NHU+mb;5gV>AHJD-tWL*gzNfRqz*8a4(p39DAZWF3N79wy(DY{~q&n^&u&+_8F z6r&4zh?wYJbZ_;kw`$71pB>eIrV<*8fFk4AQ)_qGl0MPOgjgh+LE&PfW_2icuNxav z7#NaabIHo09%bBFab*p?lzSS1r)Al!xGHB8g!e&hHfV}fTw(JAvOZ+CO+;9oW8as@ zE_Uxz)=g8uF`@GJ;J_hVJDOPCP|rFHf9!Ah_SepMX!PCa){Bj(W!?z%FPA(YH^P#y z!83i3hQpe(`3d!WI9cDXQhQ7*G=EER;Z^I2Yz^WpFH(uAN_RMa2ANM8p2&@j2mX%L z{+07EL=VV(^ez6GGG%n6{v1v`$UHfIbSLftc#m{y4~d+0S>%E;LzlMNb|~!u9S?wt z=R>Ei10>X&R&O8|L(JMdk8GD%Z|up`j8?>+o2imZ?_JL*1r}GB0`uOiNT%jJlgSaX zLlqujeKb)|&4HJ-C_c_>=NSq=TGTq7n%xWbZT9Jp9;E3R@WH&*Hu+h#6z$~{efd*i zS6yt=D>2J#7Z= zRd0q}$Z$Bb5}3JS_Qh*mr8Bc;)R|$d$BZU*#G&QRAg6jq^=CM&pJcNV?${hlB~pr; z*c^IJvN&eSpVMo~+@=gW#4Vhb`oZbh0P%D#4`jRwNOYi+)@UIZ_DVcj-kIQbQ*7N4 z35*@4V_*wj^K+i9^DGM_nlhO;zq;rD;#%CFwx@#BiiPJNfb`X0-cfW#cNC@ry6{== zyK_&ej$ITm?icVrOf}<$31O`jHktUz;yGQezabF?6TI*$KPR_(5aF`id2I>K(go(w z>vUE?_#5&DH~}HgXD`=n`1Z8*i5=bWpPmR=z8equ+ccIdT|Z*VY#!^0FTuhW^3OIM z<`fAkuKf={{e08Rh9>C%v-+U$6ZgZ;kDlc+-}7P{>Z&bElcrzRb-%bRS@oWyR=E_! ztvk-qGH3lE6?_OWe;!GFrcTEPH6sosHrA50IJeJ>pIL-5dKDT>-`A{{SS@1*v}O%p z!Tq)|c~~1#8y|DuhG<(7qdbc1)x1J8{LI$fnNNfc49xA%wwqhhv&v59^-ciHK6INY zuRprcqX$m0^Q!$`s{VULq)t)Wtd~=J^sA&OP-F*GT01zu9It?mMdetoC{fUggp7FbpGinR!BHXODlMDp|csn zrShPke5P=r(e6IL46;TPD?hR05K*h9iVewnb=T~e+klJyUK2^7lUT@hGeFTi=9&A!;*E{RNyd)U}WxXgL6!+_Ljn+!fP9GrC8TA38 zj{!EX^)GzRqAWge5~`!-@v|^kA1g$kxV~b;YOZu%W4nojujQ9{QE8^lL~+qi+(g~V zg2pml$>e|blg&|t;I&4_Xd2uyvkAsiuz+jbD@q=alpYZbZgUPw`>*gw5KQWdb8m^w zi`+5rW&-bFfeGvVo=!R0J9^y){qV)~f7dA8zci6d2@*LAAU^1e-ZH9*vlM(jDYzzJ zl9p-PVerKKJ=?A9!Ds&M|B(0FHPxcaYkG9YF?Lr7sHg0#{ZHqa!9X#jv_C3v(&Pa} zeYA7uwtGf846VI44#tgIRMNlC_&>AGim07UKpe{`Q-+c{YBIz-5*mRt zQ!B+due3$+1@-eG!^Hkc^KXk&-J0UiP|l7AgbM-{YJ$jfhRtXD{zSU*R>x40>_uZz z!JU_1$m_6o`&y?B#0p5dxfd)ngB>&bW+m6;pP}c26Jz(jN}3c^e*1fbUt=d?gFjIH zBPOsOv})hj!t+7}3b%5*^;Rr9p+3@U4s^T?a}T)Gd(gqS!2%H+@CquP%9R_eTl?$+ z=dFBfU9J($Le?+>zwdVHujP$d&A;S^yiFpQ{4m44{fRyw%|^^xtgmgJ|EwY|WQ!C8 z6_6DiMd`kb7T(~Lo?~$coO3L~N)9pmPUKYkHsQ2S%`GOay^Q&e_bX~uHXr$eHYHai zcl67fy`l!%LDr@z3Bq&NFsFqR0Xvp7`rx=Y=q;^6IhH=--C_AnFUjqdgPUy zYieAN`*cG`T`*C*OWsd>LrGKjh>9R$e?b(;9 z@9VeUFq_S3f9%3I8_P8mYTinTs|lnkw>+0>5i0^X1$sAVS1$FY4daIAi=B4)}O`FZ72d>P)-eql`tqEy9wg`ST%u6>1iUMU5gt^pa z0O2~1L1z_;A8J~p*j`P}#-3Yfo^MyEQ#GFi;?&PoIwyJ&REIEcyUiR8=_@MWcwZ~l zXQ{H^hV%52SAc)3I^! zr=uco%Fhk=5ok0_mu|Pv?7F0NoRdq(ewVih3+e@gf81#?vj$CBsxH5MIL0Z&8krlN zIoJNu))>`^ORy>=u~#z;?|ZEzz-D97a%?pOnf4`DM*~v#duHE`{uJN#=A$oyDQ>C5xtcvhMQmS z6?Cn;BeV7f;V+c!jCf(w=bH8OKjO9}ffg6$7HRs1z$X*Im)QFJAJata=~IYx?)$hS z(8I>>r-J4M-AyGWWWW#4faNPK$jv~^tfFud3SJe3qWsO4 zr?RX>hlab)F`&Uf2K}7vdHq4GFwXX3KJ`S8P4X zhlITQs#5un>hgQ9)R56Oq<%}~$uVgcKB;!bLyY+gQ*0X#WspC_f+Ioc04NbxnByBj|hjMjsMG+2b@ zz8K$weJVUkoW~LtIiNh*OGcA3x^pHZvWzYC^hgEUzqFQ{r#T)fU0nE_*SDHQ!1i>* z#|xC5ZeDKn2SsXAy3kBE`z%3_^LfGDlekc-3aTe}C*+4el*l z<)XZl*wt&TDYzmmv`@6y3ul{R>tkgtKkO#fUbI2ddGVyQ^w{hbo1~q^M@e;J=lGO} zaHTMlpJ`8vHs-{5ogrul#1Vqvm+%&5XgM?gr>m+fP<@B-l2~zoqBXNBwkg;UQHL_!G21e!g?FCm2Of&#U=ZVwu)5?o;XQMAF?1wJ_jkMu0V2f~nPLk?i2fa*f2>rP5?qVe_mM2P!owAY}F=&u%-cO(L z=3?5dV5q;?DQ?+;Kkn%yED`;LNzsG-;h87}9L6?{S*Ed-fC|W(l$Z#lN+}&sCsjQ@ zYU#!BkHf243IoDwVrlV)5gRy{ozl;VTA=N}Q1L#W$r8}RudxS4~G?dw17mvf4_$4vh7$!sV zg~`u;%D9=r^V?D_s->Hw3bGBJT!mR&;WS^$xok&<`aCN-KDtX2zt=L2#ss=;d$X96R%b(e_JqdJN5R~{gYYZZ@@@T#2Js*Im3FbWMGRX%Fc)_A|^da>f zY6<JgwB{6~_EB5U!{+;*w8B@ONF0jr(SQ zY~1OfPS6hpmEsNVfbuVAkR=9&>9VkN^m)z0)p1)pT(MzA>y zB~Cdn@AsPh#1>LC$1N|`Nxp%l0?FTgvq2x2H4pX1;pbo*M(_1hd)(1Q&=V^=tX97* zVF%s=EhcW_;q?uIa@REz^a^*uF~4P+qLVxm3%Ur~s2L8<3IBwsvAsG?|Oi znLB7E%@$oywV(L1QxzO$2948-fZqmU74lb&0Uu_H5*kr4-{c1hQ5C@G-$JYB{(B7H zi;GPZvtKe_uOt-WS$2Emb58Kv${&eC7CZDh^If^eQOM^INj*yEMsJ?@sD8zpG^KK* zX$vWM(jNU2MLE;^BHb;ZPP@7Xke5;8Wnop)H$>-)zu zD^JIiI-b&UV5i~fSk4?c!19z?nT=DL3kx@HG6w>gm6Zd{+yhH<$Pwl*<-|}5cqzHs4k^93O<8`u)XoWvG)Xs z6GEqTIKeuQGCA}wskNhK9Ap4QyJI~sZDr@icmtaMqi9TQx3PxlNNO6{RQa!UVVowp z;|Bix2K*QLoHCr`IrB)*1@7PMF<0))jA(ADT6m#w4ET#ppw(QV!hL-m7%e(@tDQE0 z_91_b>D?w5z10hCC-{`zop$7UnAyBHbyhX3S9f@DHCmsrpXhMrX#^3LW%)sT8I`VTz~!q}%HmeOauV#cN|OpRkk zKtOlD<1pNbgrtNjsi!EfQEW>P;R5uI%#VjG>gzLU>5jd3;FIbi$KKDaJ@X%qT|fV7 z7M)UFuTtj=0x+XR?;E_Ym(U*Ce{cS%wsKFOd$5vdhZ~DruRK$lVM;g-#lj@e#$l4g z+J&a8NiRPfmJQh0n;mZ0ugG}Mw#*d`$UKRmE>y_A1+@b|BdKuo2VoI(8#*Jza21=h zF^jE*sr(dE}sS}TMtMUVz0s}xfx{3a9!`D6fMvLhlw@m8qecS2=k z8>DglXu&i<5Q^NXyKso!LE7bDn?iSf@JD#DyFt&1&az*YgB>*dFkO`3kg`M13 z9(2BFx<_%BMr~@{oxm;6?yO>&vwX|leBr>ZD5u0-#h8IuV*?b$%Lo$g z0hYas&HvGvHrsz!y+A9w z=PnDS#H`R?_R4S&W*xnCzUAK@?pZ$F0Uml&Fmv#2?PuV}6l~=mdPNe3tbz!hI}YDgx45nF_5d(R=c_8qRyt%l(JA#>e${V(J<>lm9C^!(O0S=$c0@y*H+~H*g zh>2@PxkAZKKf8mR$%1Y`8iB1;>Bd;`Dzr$AJtyu*ug#;aU^BDPoh=r*dL+mT-j-ct zC{LDQRw1O}BJcC;@JLHZ0|HWtWZ$C;+fId=G8{FAY95SdSth74uusuj0lSUD!6s_H z5Q5){S_7hYu}zR29AsGZ<6iEVNEje-*XhdV@_vHm_))!uc*>3C&-g`4{UX>GJowCj zhE_;h`hijC$T>Co3^EKiF z<$?sh{Lt^I)qR`Mspg6~d08p8%6dk+-MS*5G&7w%T$at?hN0qL!M4VtqyN$H3g!;E@tG z|7d;pqhTl;F3KjgBLs3zikM|stnl|WuKf-vi#p=Gb(G*$^&YaFn`VP#FAU^kv&twT zr~Gv2E*wSCV`oXzsa@q5>k`ToPR5~krm`@l!W|7ou?g51+bUeZ;o=yb8C1cxf6oV* z^A)P~3ytU}U8d&SXoN62It{g)JcA<$($0o_r3-iGS7(rzIZxqiBxtQdwpFQ}YnR?b zw*)vU^=9xyV*$68@6YahRJ@6#4NS^1US7aYy+sT5hR%*$849 zn!Z;QCrwKtN}*7B#Gz1sj`=yxU~XRis&#giuxj1ZhxqA!5~zJpLsg&OcZWT1i4i4u z2b=@$3R|!hP&g6~cafG6l0zLm2@C)FlD73+Tc zaS0*dC&xkr8{Pr^>qQVQOyOZaTNW&ySSwU!?CRSI1Dv-*C~HlO6LBcORFTc;^}1_V_yk zja`muK&IrclRUFQt8$384~~)pa`O$ZC3Mp9$EY239JOT0=3C{>MA7cEfZT|}g_?4* zJvF(yZPo-3uN!)$B`DnG6WLzm_bkDqEW#p9kg3mpgW}RDb^AjfA(*srfvdcqD{v`V z+U){|e0=>whNd!_k3h!NSZ^s};kW)fbkRC@Uqj;iMaiX|{;~RamTOgN&8LJaH7{GB zfIdRsL8x*zx>b(Da>_i*wj|22zNN;mT0>_A4C^Q@;74^>ZxUA>VL_Gz^W&E4x>O{- zR9PST&Z8iC|Fz$?kCr`TLin?6k>!6oD9{0tg#;$3;465!Sy- zIu{d-C<@T0hCZi}t0!5rauTk7ej2elm0(ss<;kWmdHL~oti*`F977m=|7(moqq{3` z=q_%W)Bk>ss#7sbs-Pn8t&!}fBiZE7TqR@ty|suKe_A1`D4)On1{2A8=a^F^7~(@$ zo=*SNewhQ%n3mi&w2{A1EQsWeFX4)GR@Z6bwt3iI(T6bjj`_!v^5YR*)kct~(@*Y& zwrh}a36erwc;|jLA9?er&qN3Nh_}K{{R7FsTh<8NjEXJpiAW3|iXj-pS7OWQV~P?6 zyiEqE&FmT#;z5oCyv|Jpttv;)n`Z;|^3@Uv0o(d%h(28A#X3WFF-b9lFVzCyT)qw| zKo}^hk6KQ;bvqg=x(-j+T>S9Ih-`!i5nXl0|LWu$N1#i_*G-)1U`<9Vh|AP)4qL@k+kkG!KjR$ zVgxD=9Q>GMm%(q>WqmIzD*p(0s{9`Yeu+HQuwecdwZoChi?@gzTabF@A~kA&uwS+ zm=Ql9Bz8ADBXuJ(NaumAnsBn*Tbg-UA_XygVyK(yP(HrQG-(@8tpz*(^|ujm&5;G9amBh zoPYKmn_V`T$tihhHeb&d9l1P#nX2t%0==iu#2tc1Xm=XksSGwV9UUGl34UqiIyQJT zY%I^&T8+IG0UWlYad#Oyh9hJO(1M|Y%@D`6HP>%sP9V`VJ4ehPfvfi2P$A^!I$!kr zq^4GL;N;ZNgmydahOkFE8e21sA>I7HOQzsJ@j5%QVdE835pVNIez~G0GFx)nzmr9* zxy?uk6A5NzH>EzV`BfZLaRfvr-*9QW<&VE7n2f8e4Whpo+o@4znC!_yJbPd!q1>)s zW;|Ho(n65;k&6f?v$YQd%Kj!34TwoT@Z5-tC3_TM?r8x7)At zw_~NBzCA7-aucX3;c3W3zMs^WS5io}$#J8q5IqkKP!4ePT_C8$B^)6yr_g5ipOYa5 ziUnGW^*CAV51D{?3825BTA@kWA_mQAjKgd}h3L zu2G<|J(k(P-aGyPJJvplQEOPQsXr#% zVzuzh!_W}vAa@!Z6K>;z+K6mDRv(0KX^gI2*Wa*`^40BqZnRkmYbT15xj9Bmr7?CX z+n$xvx{k87xbPzH0J{XU5I!!c{!`$J->>aL5S9HiF+aiOE_4Y#P$)WqseF8ry zc=Q7GtjrsS`T{6n);7CcHxeFr! zcMB%enFVsF1a4HXyO~74E+LA&i|8`{x6#F7QgMg#GII(gMV#c4{G&OZTCs-{<(7?O zdlD~bIDe}3eHWC7QaVvbKQIY*l8tmgYre@*Y%;}ts2>4vFG0E4#%n<-Fj{tE_+@No zpXdjCwK-kh%oX1a>{pg$%x(OtpPpwia!zH{`DJxY%lKfHrV)w<;9W!%Z&lv>3EK^I z!{5nVh6B)m1&*03ii=M(L;4(Tv-|sTkqcEfAXk2vt3_v3IMR1A*mgUvc$=)Vq^7S$ zYo?aQBx&q8m+_t+MQM;r%y(kPT!=h+B=I4prwb#Moe7H3H7o3%o6@T7P6#J(pN7R4FwV9{mlf zeyeSC>nW-BT|{XgytmBQRX_QE(msx}mOPT%A3gI`Bjk|50hKJ}uYfqe$65~N8N}z4 zLV~u@VgZzvtjClThNazQ^lq($~bmGuAQo z)+^UhURo}%p4|2Oh%T<>{%(!3; zv|ZQzpeC6}gi$KlbsTJTjich=5e@P#>+bZlHU)G##LQMw8su*DlbHcg*#wT!%9pYH ztC8bV=q=~Mmi_XxwGC6Ag3`y7%vf+E`(D9IHZt;+Pl7?395TQmF?n~bES3Ylc=jQ* zc{b8G*Jz}+P^fcOMy=7$+L`_fz0%@MfI=qgc`i+o5sEQjv~=Bt$kma?v8xeI#M5}W zDSOo_iIplB`WjG!!jSU{|8!>^y1i&Q1hWD`asWuv_MefD^X4PAmkSe^VIr^GUODd+6OjFO(!O@Z;qQGxX8JP8W?2sd82moRKH>&1j2{D$X`n5ma_qWw>KML zFL0kFgmJq}NY&C2m8s!gix#S0$*FQV+?3^Z-9>4MDJ?R+JMvkMhg$K*56-B(cm5;G zi1Y2{N~aYpt_v+TK1-7(+pnBl^>oq%6c4Y3{T(W(-!4c9QZV>ft{g%7)Z5|>l zC3L44j^XX!!KELMd7tsPWSwK-XFYnsuD?&(NGGK0p-83wvMIY;iD89n1XbJxc9{>! zIIA7zG@ym$Cj^Ck3Mw3_Wmg2@qA&TJDDGa-J`w401KvKK-$GfSf2GW_cb(Vk4s6YO z&S~l;nqJ=h${rJApQ{dSyDj~s5ruu0Sy-^gM#Lqxr$am7CBZL7s#w?BC!#Om5&>Ui z`e4S*a@T_#VmX`d_2p3qGuPbyE^^Y91=`*Jq>_LrM(PJhYU~`!06lyzn|?)n_qh{Z z7&LIl%9hA}K^0rAK4g4TvPbr29)+7?#~RYvrR)&a`=3H%rMEViCvhm-{1KT9l#VT2 z%Yxc$NEA3qmwg(sYT5v4_+7r)v!)UWEmqxZ`E}c$esFx#=;a#};}B7EIEFyT&|u)f zSlZ!efco)k)L+Z@CPVjvF8Td54*6L>*_Eb|to*UymR1|+xDUqwlhZ4eINH zlh1_^&Pm?)%yK2V4fQ|`7N>F4O|?Mi4nAF+JZ(hFDMCB}=T*FH1!8Q!6#WAbX{NC! zs%0>WJL$-8vAQFfD`Y$RdY#2iNK=E*5v$6Qa>Mn>w!Z9P&Lx5*<+!YXS$@F8k22F^ zBVR9Vk@Cj;%Q3yS7QtE_sxfWL^Nr#Nnc83W<>yDu3;0vfLh(n`ZSgjfT!- z6#`sWE{&1J%?%A0QMd4UO%>!A2;jaZr$?QdZ-DtS8((qu6SoE8Gq8a=`eS{?A+x0z zGA}9v3Z&^7`gN#Z$(o<`-980(jx68L&jrL;$K83043O)JxW=u}JTriAi0^K`3xg|A zOcz~oA4@`?U#7ZoO8bJ~cj=E$PF^uxEb0ZFYmdLKTHK@bWZN{`45;H``)C5ZIb4%Q3fRaa;~`vWlfLd|+qx$7g)QP~nJ7w=u)(mW2ldW9u>5n`|QV zuIi7-mi8*@dpzM7dbT)=9+kui(YvO+_fXa5h=0H0Oe-7^<4 zs;4jTfLjuZWp4O4zzhSlcg4wi5WQgi=m}OLb z!AjfgaJfNRE-#}~_^d`<$R_r?rpp+x(R#-s_iJ{ep1lbvNH(Xia@~~K+OD23K;(B_ zqK3Mw^X-H?8AAVWT@7YSis=2L(-WErRa|G`&_o{qEF~p|_vvo6{+eZ|Y?^E|@0)F7 zs4Ov%tt&TNJj1D<7Tg1%K)8E!+?}GEz0%}%{>dB|`KTVIMk;(j^V6qOinLVf?Pf2@ zDV-t8;l<}jj^U7mOTfSxLGNFy1fJ8OUiyJ~slc*Zt#4&5|DgS4(Xv|hyOMI zyKn#4QN}ya3i8`0TY7(eru4{t;0ct)q`L4ZEjZwKerSNl9fY!GPp4h58uAa`pTTm; zq7(d396&dt=Qm8+H(dB!>&1j5l6MV>m-@4zMkgyLn-{g4{=a}+k=D*XE@O=gc_GQ5ied$U(xl2(NXs-RyKD+c$;W zG+Z$t|68_dmM)FVNBb7D2rEteJ^vv-RU~idW9Ar*3WT+GNlBj;U5Lb1+iX29pqwln z@uB?({oytvJ9A|m8+nSQyuHi}k?V>}I&q5VAl9axYh4fL%oI$G4?R-+5Gtx#>R1JpF2~T zKWujyF@=)K_v`KZ%`matVUS1);N(OTBLqGD&`j{N5t@B}Rroh)R4#O+&+u5d{~&e2 zsnqECkuL6V|Ax}MnuThAvY;`MG>=Eq;%7=(2&}cRK{$&V`r7Ii$=ct#EL=uj0Xni6 z+gQwUQQ8QKgTdV4$x5=w=Ic$+Su8OMlJLLI_aDa?21Jbw(_^0meHp(dDVWe<|YSDc}92`S?m4k5Y&#)r92a^}#gZBA|x>c?cLFy8MmoD1y zw`E-r54YPQnYbsTb}>(YtID)D)ixxcC~a0F(whU>#LJ2R`9*U^@lBPGCF5!AuGsGB zE;AXoLgsfum*h=6z>pY&f6=2_JP6PAP+6J~vZb=AmdM^iD&cV*yJrWjmh)Y5Py;dp z?xKCIk|6Ah5hlyYqJ#{xBnE>|0Jgk4+|>pEK8Fa)W!`9Zfq4LmUObXbKWXSkEWLqR z){D8TEO&Pq3R7pa%AP&>EIv@$Q90ME>Ifcd(q&yz2hv5AuR85R(u)xaA;dyk`qi|o zyNtg%gS1j}k?%9G#2$b{40%MWc$UuRCM7%7##!gEj=;ghZHw#dMI6!~V3P_%5GH`} zS!v>^i^5pH7nuFDd&d%hq|D54wfN3gEyu<(;Qj1{%vfy~rLvPTH!-&L&(!cLIoI7hKp6v&6aSv2G}=Y}}?cbpiTN=^^I)(~efc za4;koyv#{X;hcmze$hn_VMT$w8zS?CBM|=QwZwbG)~$A#!wjYP_9*R29g6on@K%A8CWC~w=aMS+d4O87~nds$?6B#52`kqz# zP$A(cV0)sCKbNnynh1kS>DF>WvrXDl5fWe*s#upCUU|(hR>Aczjhr{fEqhWIWtL#* zkS71iL3m5bZ|gs*&{4SmDPm+m(G?zWI$c_M=Hez&U(qLj?M zJ}{TO$jaQ2J4n_<1CyWI4O>}Z^yR9@FvYKLMI&V|s4xxqzMjf%}{m{5Gf z&gR-9$PR2qDn`|?l6B6INd4;w!{qFUqcPnSjzw{pnK`i$*h&*Fh)7}=zGEm9*m48- zW0h=)YuE40yvvFFTdm9YDi0LkMI##?*}CtHNx#he#@{E_sEhKBdg2Z&<(FSprG|P` zymtle`gZ@4z9#$J#z*>$tI?YXjXe`G8hNuj28CmRy;+Zi{?9?jJ+uFFp4rDPIQ^WRGB8C)EkF{ap>js?jQlY2gonPl=)F~|1$^+%Joq_m zo&)^AxGs)HkEyYDwg5*a_jH>*j>r$JGU`6pp!h>~9i45kl(y7|T?*J!w%*W|N>ClI z+j-fsmaJPIUa0z}-^iRbF@tU9na}WcDOQb0bCzinsx}eTU&Wghp3g;sJcao>qL7ZY z6x5Wxz2=z<5_K9*fIhOqXt+xY_62$$8d2&p;ZEzTIW&xXvc0?dhH$*JRU*YuBfWQR z4pE`-B9_agR%#4J&PbvaUEVGhcC-zn15nysyvpMZ)tSWnE0scG3Yu`F?(d(;^Dn&k z$KF+nIc(^(Uk8o0SAW)Mcd;+OHC`pp{1a+3JuQq#4Bs<2Tz9sy@fh5ad^E?ffbipO z1gz}bVp(*+(B^*T5`LEUkX`&;{yN(t{&;kDn&=8~`}cp_1!T{g+dpEEx3R2oM0evu z;YVz_NV2kvi>t9AbZ)4zM`e&8^l)wOlFUGB#GK;UcicOqvkk2Jnt zg@vuXgd|+<8$$(_L8Ws^(FCFHEpBd@;1lC@jJ(?AZF75}jqs;gJ3;pvU8`t%p=`|* zvsyE=OW+Kxy}=_od*_V&Xx6L>)V3p2Y>Wjkx& zwcYm?tz%^-I%gS+kt)OeOlv6jjR{QO>PN2~GXp|eNp5%vW=%=-OIJ+A7l~U@|DOw_ z!csOkq_&$!+X8(@-jRm0v>f&6?{=r92_hp0#y zOV_^|yd~uwXULRBodA829y;NwqK7apw3S1=ZSWqio-&Gw5KgQo`Q*Esjbx7L*iK#n zie3^zo&~l1_9l9=s6A07wv1sijqOb4!(Z%>8<0}rainh%^U-cJAVcy=tBu)AjwOA`Wr1>ycI4!-r*2OPV13bmqh*fpCaYMZ&qs>cdGRPG zb;i#Y^dmJIxr3`O15M+fm0q1(Qc5(mQ2WAikx`5Q?p|u zw_xyHef7ZJr*e$kRey|8PTMTcw+5H`C%y(yO)L$A`v z0}B%oCuAi7DR!Q`Bzxid@ueZJml7 zxpS|_FFO9z>V}_cfA?5TR6Oz4UnuI|YhOT#`~%sQ)#T0aKoJ@2hS|S{x%Nc{jh`*p|We_+MU6}+zuUo7@D3ic1(QW^+-!KU0}Ym^9=jnu#n!f$nd=kCJstLQCR=-L|3S$j<~ zX0f~fyIoUGJE42)D)l}61~k(IHF^-`pcc5(i6&-XU#SfYa)v3occGGsFv4r1{luGa z)U&SWM@}y3&vFTDgI5H@wuJb~hT!;;dr-ebK}WsrgpO!cldLGACm<>bdfrR)D9DR7 zbX<$f7ntDAKpCoj^Eq3R8iENkJfFni0)IWZ2rims5fDgeE+2q#`x;sf50YFnYu2rf zRr%)(H&1*C!god>gS8!|Ptb2qB(rYdS;{zcb=bH*ZdUY*&#*-kXtnYTx4Tefl85bB zU2X7UOL~!e3b!T}?e{P`=nCWuG~}J+luOm!)@{v($B(wU-&h~ElcssY_6CB`s~ z_{gnxwBZ*hGr9CGOkFfeIm^!tJ$TGELqUCDuj?<4{sRb!P(prVLG-y7Q&1T2xvDl? z0qDco5A)iJH?X6ZyQvHmUFwrIum#^d>=_rXqCxMMor=B+MtnXM6T*$jgurR}`vgmP z=UzuHn}`Xjsi;w`rom<^c^|$7eMFNr7L;!HAo?4756+m0k97@(HaLcEdl%VQHIcY> z&Y;!uTo~&q);nMow0jEZY}U%Ie=al|v-2zV{A2DvaFB3f?IhOY)x?%@;SHi501hn z%(C{WAtGAD@mpFfHHxuOmP-V+o|yV^rUYMn3e3CGn*u6(SynJ(D2q^!zoCLZZRdVQ ztRGMUI%lgRoqhY51TgtH_z<5ZV?dPaRuW9KH535d%n={*@fQ^Plv=#{%_ah@v)po= zu*fB+HhWm2U{GDy77r$Ey~)2*M^Lf{tRDeB?wV`VhkPZ=^d>dxb~R8y@$oe|RtZ+& zN0uuK8U61-aW86&F<%L*JvhPX&jt%!rqh*@aq{7!{2AIi+eqI@M{}zj6hpFB4HeYt z2s7Tk+;1Ujxf9{G8OpH8w4j`szpyu%f3MbsQbb+UmeMAggmW?4n`Q)AJvU>J=4`0i z2f&OB7zi7^m0zkFX%mQ^eUbgT2S(Hytw4YkP|=EZD5eJq7>bz4yI)G~Loeqgd?1d3 z;FMHY3EI-lzbi83gTt414k>XDZKfUC`6jQEf6%*gH7?6?_^&$RPgM~AX(v>Md}kL7 z$vef3I~{&ksT2bdarXOW*gfQK!O_M1vSj$PxKqUDiwsXK!DL5dh3b;vBwLB%P%7Sj z%zJf!7=_LVu>EQGrOm)%i_}5;qEU&?>B&vEEAP(sQbhUksEdGh;F+nXKh6ygt_7_@ zTTqf^$*K#$NzP0-Vds-~z$4a+7UzgalJPfCbdG)a_uqgAeaGw94Zb)^8_irK=Hk^@ z{ef`);2t{XU0rjH>Bc31^X=XwZPa#aBhXYRKhuO}>^?Q3X5$~Tqkxy8Ul}O&_4KZb zAp6g}vwdeyl|UJfTMYwMyMwphifgnEaos=hW76Fn4|Zr9Sq;_> zWomXe-E9#T5RMuZ^rWtA{(kw0vbtOW>tJeB#<;AMY299$`K3$H$hbZ*p~Ix`QYjX1 zkOLf=x=pko(qhYMxKe_7X_VK_fCSXt@vcV~6{t0Sl3jo64vxLfD~XQqSuB-XtQ$)d zN|BEYECig_l__Y2Tc-KlbTCk4==}<4|AlK0#;$0Rz8z&ac`cFz8If=YG8l0=clQH- z=bzc{{f)HhI%M-MK6EEgEgtyLUg&e( zE!5dLioPQ-1GR7p$6Hw|v%*#7rJuqfS;$6U3(2|37PB~QJ(Cp>d<@|x;A)7c9E87Q zcg-Y340!1ymz~I;O1Fp4mX>F4l$(`Ao80o4_@_KmufWk-oI+QAe1=f9H6G@+Hh24w zpv=2<4I_9KR4ZvaRbO!VC%fa~Z1LpZKb75lyPWp5hnFwGv;@`58e@5LfNh3d^@v^M zy<0bw6ow~NPO~i<1ZRVkXGJlOw$H+dA<={j-!1+UC=_3C*pk~t| z4T#(FH+R}x;_poUnt5a^dP^(dCVm(7Z0$}V6~cPHHWETZhZW5Ax(0fGI}!66_)Py? z()@Amg6?Urvj*QK{Gr#yLDw!O($Oqlg0JHj&o@19BM`H

ar`$tz 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 literal 0 HcmV?d00001 diff --git a/BeatRecorder/BeatRecorder.csproj b/BeatRecorder/BeatRecorder.csproj index 9c2fde2..a81f700 100644 --- a/BeatRecorder/BeatRecorder.csproj +++ b/BeatRecorder/BeatRecorder.csproj @@ -37,6 +37,7 @@ + diff --git a/BeatRecorder/DataPullerWebSocket/DataPuller.cs b/BeatRecorder/DataPullerWebSocket/DataPuller.cs deleted file mode 100644 index b4322ba..0000000 --- a/BeatRecorder/DataPullerWebSocket/DataPuller.cs +++ /dev/null @@ -1,511 +0,0 @@ -namespace BeatRecorder; - -class DataPuller -{ - internal static void MapDataMessageRecieved(string e) - { - DataPullerStatus.DataPullerMain _status; - - try - { - _status = JsonConvert.DeserializeObject(e); - } - catch (Exception ex) - { - LogFatal($"[BS-DP1] Unable to convert BSDataPuller message into an dictionary: {ex}"); - return; - } - - if (DataPullerStatus.DataPullerInLevel != _status.InLevel) - { - if (!DataPullerStatus.DataPullerInLevel && _status.InLevel) - { - DataPullerStatus.DataPullerInLevel = true; - LogDebug("[BS-DP1] Song started."); - LogInfo($"[BS-DP1] Started playing \"{_status.SongName}\" by \"{_status.SongAuthor}\""); - - DataPullerStatus.DataPullerCurrentBeatmap = _status; - - try - { - DataPullerStatus.CurrentSongCombo = 0; - _ = OBSWebSocket.StartRecording(); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSIngameScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSIngameScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - } - else if (DataPullerStatus.DataPullerInLevel && !_status.InLevel) - { - Thread.Sleep(500); - DataPullerStatus.DataPullerInLevel = false; - DataPullerStatus.DataPullerPaused = false; - LogDebug("[BS-DP1] Menu entered."); - LogInfo($"[BS-DP1] Stopped playing \"{_status.SongName}\" by \"{_status.SongAuthor}\""); - - try - { - DataPullerStatus.DataPullerCurrentBeatmap = _status; - - DataPullerStatus.DataPullerLastPerformance = DataPullerStatus.DataPullerCurrentPerformance; - DataPullerStatus.DataPullerLastBeatmap = DataPullerStatus.DataPullerCurrentBeatmap; - DataPullerStatus.LastSongCombo = DataPullerStatus.CurrentSongCombo; - - _ = OBSWebSocket.StopRecording(OBSWebSocketStatus.CancelStopRecordingDelay.Token); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSMenuScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSMenuScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - } - } - - if (_status.InLevel) - { - if (DataPullerStatus.DataPullerPaused != _status.LevelPaused) - { - if (!DataPullerStatus.DataPullerPaused && _status.LevelPaused) - { - DataPullerStatus.DataPullerPaused = true; - LogInfo("[BS-DP1] Song paused."); - - try - { - if (Program.LoadedSettings.PauseRecordingOnIngamePause) - if (Program.obsWebSocket.IsStarted) - Program.obsWebSocket.Send($"{{\"request-type\":\"PauseRecording\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSPauseScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSPauseScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - } - else if (DataPullerStatus.DataPullerPaused && !_status.LevelPaused) - { - DataPullerStatus.DataPullerPaused = false; - LogInfo("[BS-DP1] Song resumed."); - - try - { - if (Program.LoadedSettings.PauseRecordingOnIngamePause) - if (Program.obsWebSocket.IsStarted) - Program.obsWebSocket.Send($"{{\"request-type\":\"ResumeRecording\", \"message-id\":\"ResumeRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSIngameScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSIngameScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-DP1] {ex}"); - return; - } - } - } - } - } - - internal static void LiveDataMessageRecieved(string e) - { - DataPullerStatus.DataPullerData _status; - - try - { - _status = JsonConvert.DeserializeObject(e); - } - catch (Exception ex) - { - LogFatal($"[BS-DP2] Unable to convert BSDataPuller message into an dictionary: {ex}"); - return; - } - - if (DataPullerStatus.DataPullerInLevel) - DataPullerStatus.DataPullerCurrentPerformance = _status; - - if (DataPullerStatus.CurrentSongCombo < _status.Combo) - DataPullerStatus.CurrentSongCombo = _status.Combo; - } - - internal static void MapDataReconnected(ReconnectionInfo msg) - { - Program.SendNotification("Connected to Beat Saber", 1000, MessageType.INFO); - if (msg.Type != ReconnectionType.Initial) - { - LogWarn($"[BS-DP1] Reconnected: {msg.Type}"); - Objects.LastDP1Warning = ConnectionTypeWarning.CONNECTED; - } - } - - internal static void LiveDataReconnected(ReconnectionInfo msg) - { - if (msg.Type != ReconnectionType.Initial) - { - LogWarn($"[BS-DP2] Reconnected: {msg.Type}"); - } - } - - internal static void MapDataDisconnected(DisconnectionInfo msg) - { - try - { - Process[] processCollection = Process.GetProcesses(); - - if (!processCollection.Any(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber"))) - { - if (Objects.LastDP1Warning != ConnectionTypeWarning.NO_PROCESS) - { - LogWarn($"[BS-DP1] Couldn't find a BeatSaber process, is BeatSaber started? ({msg.Type})"); - Program.SendNotification("Couldn't connect to BeatSaber, is it even running?", 5000, MessageType.ERROR); - } - Objects.LastDP1Warning = ConnectionTypeWarning.NO_PROCESS; - } - else - { - bool FoundWebSocketDll = false; - - string InstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber")).MainModule.FileName; - InstallationDirectory = InstallationDirectory.Remove(InstallationDirectory.LastIndexOf("\\"), InstallationDirectory.Length - InstallationDirectory.LastIndexOf("\\")); - - if (Directory.GetDirectories(InstallationDirectory).Any(x => x.ToLower().EndsWith("plugins"))) - { - if (Directory.GetFiles($"{InstallationDirectory}\\Plugins").Any(x => x.Contains("DataPuller") && x.EndsWith(".dll"))) - { - FoundWebSocketDll = true; - } - } - else - { - if (Objects.LastDP1Warning != ConnectionTypeWarning.NOT_MODDED) - { - LogFatal($"[BS-DP1] Beat Saber seems to be running but the BSDataPuller modifaction doesn't seem to be installed. Is your game even modded? (If haven't modded it, please do it: https://bit.ly/2TAvenk. If already modded, install BSDataPuller: https://bit.ly/3mcvC7g) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Have you modded your game?", 10000, MessageType.ERROR); - } - Objects.LastDP1Warning = ConnectionTypeWarning.NOT_MODDED; - } - - if (FoundWebSocketDll) - { - if (Objects.LastDP1Warning != ConnectionTypeWarning.MOD_INSTALLED) - { - LogFatal($"[BS-DP1] Beat Saber seems to be running and the BSDataPuller modifaction seems to be installed. Please make sure you put in the right port and you installed all of BSDataPuller' dependiencies! (If not installed, please install it: https://bit.ly/3mcvC7g) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Please make sure you selected the right port.", 10000, MessageType.ERROR); - } - Objects.LastDP1Warning = ConnectionTypeWarning.MOD_INSTALLED; - } - else - { - if (Objects.LastDP1Warning != ConnectionTypeWarning.MOD_NOT_INSTALLED) - { - LogFatal($"[BS-DP1] Beat Saber seems to be running but the BSDataPuller modifaction doesn't seem to be installed. Please make sure to install BSDataPuller! (If not installed, please install it: https://bit.ly/3mcvC7g) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Please make sure DataPuller is installed.", 10000, MessageType.ERROR); - } - Objects.LastDP1Warning = ConnectionTypeWarning.MOD_NOT_INSTALLED; - } - } - } - catch (Exception ex) - { - LogError($"[BS-DP1] Failed to check if BSDataPuller is installed: (Disconnect Reason: {msg.Type}) {ex}"); - } - } - - internal static void LiveDataDisconnected(DisconnectionInfo msg) - { - if (Program.beatSaberWebSocket.IsRunning) - LogError($"[BS-DP2] Disconnected: {msg.Type}"); - else - LogDebug($"[BS-DP2] Disconnected: {msg.Type}"); - } - - internal static void HandleFile(DataPullerStatus.DataPullerMain BeatmapInfo, DataPullerStatus.DataPullerData PerformanceInfo, string OldFileName, int HighestCombo) - { - if (BeatmapInfo != null) - { - LogDebug($"[BR] BeatmapInfo: {JsonConvert.SerializeObject(BeatmapInfo)}"); - LogDebug($"[BR] PerformanceInfo: {JsonConvert.SerializeObject(PerformanceInfo)}"); - LogDebug($"[BR] OldFileName: {OldFileName}"); - LogDebug($"[BR] HighestCombo: {HighestCombo}"); - bool DeleteFile = false; - string NewName = Program.LoadedSettings.FileFormat; - - if (PerformanceInfo != null) - { - // Generate FileName-based on Config File - - if (NewName.Contains("")) - { - NewName = PerformanceInfo.Rank is not "" and not null - ? NewName.Replace("", PerformanceInfo.Rank) - : NewName.Replace("", "E"); - } - - if (NewName.Contains("")) - { - string GeneratedAccuracy = ""; - - if ((BeatmapInfo.LevelFailed || PerformanceInfo.PlayerHealth <= 0) && BeatmapInfo.Modifiers.noFailOn0Energy) - { - LogDebug($"[BR] Soft-Failed."); - if (Program.LoadedSettings.DeleteSoftFailed) - { - LogDebug($"[BR] Soft-Failed. Deletion requested."); - DeleteFile = true; - } - - GeneratedAccuracy = $"NF-"; - } - - if (BeatmapInfo.LevelFinished) - { - LogDebug($"[BR] Level finished"); - GeneratedAccuracy += $"{Math.Round(PerformanceInfo.Accuracy, 2)}"; - } - else if (BeatmapInfo.LevelQuit) - { - LogDebug($"[BR] Level quit"); - if (Program.LoadedSettings.DeleteQuit) - { - LogDebug($"[BR] Quit. Deletion requested."); - DeleteFile = true; - - if (GeneratedAccuracy == "NF-") - if (!Program.LoadedSettings.DeleteIfQuitAfterSoftFailed) - { - LogDebug($"[BR] Soft-Failed but quit, deletion request reverted."); - DeleteFile = false; - } - } - - GeneratedAccuracy += $"QUIT"; - } - else if (BeatmapInfo.LevelFailed && !BeatmapInfo.Modifiers.noFailOn0Energy) - { - LogDebug($"[BR] Level failed."); - if (Program.LoadedSettings.DeleteFailed) - { - LogDebug($"[BR] Failed. Deletion requested."); - DeleteFile = true; - } - else - DeleteFile = false; - - GeneratedAccuracy = $"FAILED"; - } - else - { - if (!BeatmapInfo.LevelQuit && !BeatmapInfo.LevelFinished) - { - LogDebug($"[BR] Level restarted"); - if (Program.LoadedSettings.DeleteQuit) - { - LogDebug($"[BR] Quit. Deletion requested."); - DeleteFile = true; - - if (GeneratedAccuracy == "NF-") - if (!Program.LoadedSettings.DeleteIfQuitAfterSoftFailed) - { - LogDebug($"[BR] Soft-Failed but quit, deletion request reverted."); - DeleteFile = false; - } - } - - GeneratedAccuracy += $"QUIT"; - } - else - { - LogDebug($"[BR] Level finished?"); - GeneratedAccuracy += $"{Math.Round(PerformanceInfo.Accuracy, 2)}"; - } - } - - LogDebug($"[BR] {GeneratedAccuracy}"); - NewName = NewName.Replace("", GeneratedAccuracy); - } - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{HighestCombo}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.ScoreWithMultipliers}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.Score}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.Misses}"); - } - else - { - // Generate FileName-based on Config File (but without performance stats) - - if (NewName.Contains("")) - NewName = NewName.Replace("", "Z"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", "00.00"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - } - - if (Program.LoadedSettings.DeleteIfShorterThan > OBSWebSocketStatus.RecordingSeconds) - { - LogDebug($"[BR] The recording is too short. Deletion requested."); - DeleteFile = true; - } - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.SongName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{BeatmapInfo.SongName}{(!string.IsNullOrWhiteSpace(BeatmapInfo.SongSubName) ? $" {BeatmapInfo.SongSubName}" : "")}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.SongAuthor); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.SongSubName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.Mapper); - - if (NewName.Contains("") && BeatmapInfo.Hash != null) - NewName = NewName.Replace("", BeatmapInfo.Hash.ToString()); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.BPM.ToString()); - - if (NewName.Contains("")) - { - NewName = BeatmapInfo.Difficulty.ToLower() switch - { - "expertplus" => NewName.Replace("", "Expert+"), - _ => NewName.Replace("", BeatmapInfo.Difficulty), - }; - } - - if (NewName.Contains("")) - { - NewName = BeatmapInfo.Difficulty.ToLower() switch - { - "expert" => NewName.Replace("", "EX"), - "expert+" or "expertplus" => NewName.Replace("", "EX+"), - _ => NewName.Replace("", BeatmapInfo.Difficulty.Remove(1, BeatmapInfo.Difficulty.Length - 1)), - }; - } - - if (File.Exists($"{OldFileName}")) - { - - string FileExist = ""; - - FileInfo fileInfo = new(OldFileName); - - while (File.Exists($"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{fileInfo.Extension}")) - { - FileExist += "_"; - } - - foreach (char b in Path.GetInvalidFileNameChars()) - { - NewName = NewName.Replace(b, '_'); - } - - string FileExists = ""; - int FileExistsCount = 2; - - string NewFileName = $"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{FileExists}{fileInfo.Extension}"; - - while (File.Exists(NewFileName)) - { - FileExist = $" ({FileExistsCount})"; - NewFileName = $"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{FileExists}{fileInfo.Extension}"; - FileExistsCount++; - } - - try - { - if (!DeleteFile) - { - LogInfo($"[BR] Renaming \"{fileInfo.Name}\" to \"{NewName}{FileExists}{fileInfo.Extension}\".."); - File.Move(OldFileName, NewFileName); - LogInfo($"[BR] Successfully renamed."); - Program.SendNotification("Recording renamed.", 1000, MessageType.INFO); - } - else - { - LogInfo($"[BR] Deleting \"{fileInfo.Name}\".."); - File.Delete(OldFileName); - LogInfo($"[BR] Successfully deleted."); - Program.SendNotification("Recording deleted.", 1000, MessageType.INFO); - } - } - catch (Exception ex) - { - LogError($"[BR] {ex}."); - } - } - else - { - LogError($"[BR] {OldFileName} doesn't exist."); - } - } - else - { - LogError($"[BR] Last recorded file can't be renamed."); - } - } -} diff --git a/BeatRecorder/Entities/BeatSaber/BeatSaberPlus.cs b/BeatRecorder/Entities/BeatSaber/BeatSaberPlus.cs new file mode 100644 index 0000000..7c0529a --- /dev/null +++ b/BeatRecorder/Entities/BeatSaber/BeatSaberPlus.cs @@ -0,0 +1,38 @@ +namespace BeatRecorder.Entities; +public class BeatSaberPlus +{ + public string _type { get; set; } + public string _event { get; set; } + public int protocolVersion { get; set; } + public string gameVersion { get; set; } + public Mapinfochanged mapInfoChanged { get; set; } + public string gameStateChanged { get; set; } + public Scoreevent scoreEvent { get; set; } + + public class Mapinfochanged + { + public string level_id { get; set; } + public string name { get; set; } + public string sub_name { get; set; } + public string artist { get; set; } + public string mapper { get; set; } + public string characteristic { get; set; } + public string difficulty { get; set; } + public int duration { get; set; } + public float BPM { get; set; } + public float PP { get; set; } + public string BSRKey { get; set; } + public string coverRaw { get; set; } + } + + public class Scoreevent + { + public float time { get; set; } + public int score { get; set; } + public float accuracy { get; set; } + public int combo { get; set; } + public int missCount { get; set; } + public float currentHealth { get; set; } + } + +} diff --git a/BeatRecorder/Entities/BeatSaber/DataPullerData.cs b/BeatRecorder/Entities/BeatSaber/DataPullerData.cs new file mode 100644 index 0000000..fd251a2 --- /dev/null +++ b/BeatRecorder/Entities/BeatSaber/DataPullerData.cs @@ -0,0 +1,20 @@ +namespace BeatRecorder.Entities; + +public class DataPullerData +{ + public int Score { get; set; } + public int ScoreWithMultipliers { get; set; } + public int MaxScore { get; set; } + public int MaxScoreWithMultipliers { get; set; } + public string Rank { get; set; } + public bool FullCombo { get; set; } + public int Combo { get; set; } + public int Misses { get; set; } + public float Accuracy { get; set; } + public int[] BlockHitScore { get; set; } + public float PlayerHealth { get; set; } + public int ColorType { get; set; } + public int TimeElapsed { get; set; } + public long unixTimestamp { get; set; } + public int? EventTrigger { get; set; } +} diff --git a/BeatRecorder/Entities/BeatSaber/DataPullerMain.cs b/BeatRecorder/Entities/BeatSaber/DataPullerMain.cs new file mode 100644 index 0000000..f609014 --- /dev/null +++ b/BeatRecorder/Entities/BeatSaber/DataPullerMain.cs @@ -0,0 +1,63 @@ +namespace BeatRecorder.Entities; + +public class DataPullerMain +{ + public string GameVersion { get; set; } + public string PluginVersion { get; set; } + public bool InLevel { get; set; } + public bool LevelPaused { get; set; } + public bool LevelFinished { get; set; } + public bool LevelFailed { get; set; } + public bool LevelQuit { get; set; } + public string Hash { get; set; } + public string SongName { get; set; } + public string SongSubName { get; set; } + public string SongAuthor { get; set; } + public string Mapper { get; set; } + public string BSRKey { get; set; } + public string coverImage { get; set; } + public int Length { get; set; } + public float TimeScale { get; set; } + public string MapType { get; set; } + public string Difficulty { get; set; } + public string CustomDifficultyLabel { get; set; } + public int BPM { get; set; } + public float NJS { get; set; } + public Modifier Modifiers { get; set; } + public float ModifiersMultiplier { get; set; } + public bool PracticeMode { get; set; } + public Practicemodemodifiers PracticeModeModifiers { get; set; } + public float PP { get; set; } + public float Star { get; set; } + public bool IsMultiplayer { get; set; } + public int PreviousRecord { get; set; } + public string PreviousBSR { get; set; } + public long unixTimestamp { get; set; } + + public class Modifier + { + public bool noFailOn0Energy { get; set; } + public bool oneLife { get; set; } + public bool fourLives { get; set; } + public bool noBombs { get; set; } + public bool noWalls { get; set; } + public bool noArrows { get; set; } + public bool ghostNotes { get; set; } + public bool disappearingArrows { get; set; } + public bool smallNotes { get; set; } + public bool proMode { get; set; } + public bool strictAngles { get; set; } + public bool zenMode { get; set; } + public bool slowerSong { get; set; } + public bool fasterSong { get; set; } + public bool superFastSong { get; set; } + } + + public class Practicemodemodifiers + { + public float songSpeedMul { get; set; } + public float startInAdvanceAndClearNotes { get; set; } + public float startSongTime { get; set; } + } + +} diff --git a/BeatRecorder/Entities/BeatSaber/DataPullerStatus.cs b/BeatRecorder/Entities/BeatSaber/DataPullerStatus.cs deleted file mode 100644 index bce5772..0000000 --- a/BeatRecorder/Entities/BeatSaber/DataPullerStatus.cs +++ /dev/null @@ -1,95 +0,0 @@ -namespace BeatRecorder.Entities; - -class DataPullerStatus -{ - internal static DataPullerStatus.DataPullerData DataPullerLastPerformance { get; set; } - internal static DataPullerStatus.DataPullerMain DataPullerLastBeatmap { get; set; } - - internal static DataPullerStatus.DataPullerData DataPullerCurrentPerformance { get; set; } - internal static DataPullerStatus.DataPullerMain DataPullerCurrentBeatmap { get; set; } - - - - internal static int LastSongCombo = 0; - internal static int CurrentSongCombo = 0; - internal static bool DataPullerInLevel = false; - internal static bool DataPullerPaused = false; - - public class DataPullerMain - { - public string GameVersion { get; set; } - public string PluginVersion { get; set; } - public bool InLevel { get; set; } - public bool LevelPaused { get; set; } - public bool LevelFinished { get; set; } - public bool LevelFailed { get; set; } - public bool LevelQuit { get; set; } - public object Hash { get; set; } - public string SongName { get; set; } - public string SongSubName { get; set; } - public string SongAuthor { get; set; } - public string Mapper { get; set; } - public object BSRKey { get; set; } - public object coverImage { get; set; } - public int Length { get; set; } - public float TimeScale { get; set; } - public string MapType { get; set; } - public string Difficulty { get; set; } - public object CustomDifficultyLabel { get; set; } - public int BPM { get; set; } - public float NJS { get; set; } - public Modifiers Modifiers { get; set; } - public float ModifiersMultiplier { get; set; } - public bool PracticeMode { get; set; } - public Practicemodemodifiers PracticeModeModifiers { get; set; } - public float PP { get; set; } - public float Star { get; set; } - public bool IsMultiplayer { get; set; } - public int PreviousRecord { get; set; } - public object PreviousBSR { get; set; } - } - - public class Modifiers - { - public bool noFailOn0Energy { get; set; } - public bool oneLife { get; set; } - public bool fourLives { get; set; } - public bool noBombs { get; set; } - public bool noWalls { get; set; } - public bool noArrows { get; set; } - public bool ghostNotes { get; set; } - public bool disappearingArrows { get; set; } - public bool smallNotes { get; set; } - public bool proMode { get; set; } - public bool strictAngles { get; set; } - public bool zenMode { get; set; } - public bool slowerSong { get; set; } - public bool fasterSong { get; set; } - public bool superFastSong { get; set; } - } - - public class Practicemodemodifiers - { - public float songSpeedMul { get; set; } - public float startInAdvanceAndClearNotes { get; set; } - public float startSongTime { get; set; } - } - - - public class DataPullerData - { - public int Score { get; set; } - public int ScoreWithMultipliers { get; set; } - public int MaxScore { get; set; } - public int MaxScoreWithMultipliers { get; set; } - public string Rank { get; set; } - public bool FullCombo { get; set; } - public int Combo { get; set; } - public int Misses { get; set; } - public float Accuracy { get; set; } - public int[] BlockHitScore { get; set; } - public float PlayerHealth { get; set; } - public int TimeElapsed { get; set; } - } - -} \ No newline at end of file diff --git a/BeatRecorder/Entities/BeatSaber/HttpStatusStatus.cs b/BeatRecorder/Entities/BeatSaber/HttpStatus.cs similarity index 81% rename from BeatRecorder/Entities/BeatSaber/HttpStatusStatus.cs rename to BeatRecorder/Entities/BeatSaber/HttpStatus.cs index 06ab71d..784a1d6 100644 --- a/BeatRecorder/Entities/BeatSaber/HttpStatusStatus.cs +++ b/BeatRecorder/Entities/BeatSaber/HttpStatus.cs @@ -1,25 +1,10 @@ namespace BeatRecorder.Entities; -class HttpStatusStatus +public class HttpStatus { - internal static HttpStatusStatus.Performance HttpStatusLastPerformance { get; set; } - internal static HttpStatusStatus.Beatmap HttpStatusLastBeatmap { get; set; } - - internal static HttpStatusStatus.Performance HttpStatusCurrentPerformance { get; set; } - internal static HttpStatusStatus.Beatmap HttpStatusCurrentBeatmap { get; set; } - - internal static bool FinishedLastSong = false; - internal static bool FailedLastSong = false; - - internal static bool FinishedCurrentSong = false; - internal static bool FailedCurrentSong = false; - - public class BeatSaberEvent - { - public string @event { get; set; } - public long time { get; set; } - public Status status { get; set; } - } + public string @event { get; set; } + public long time { get; set; } + public Status status { get; set; } public class Status { @@ -50,7 +35,7 @@ public class Beatmap public float noteJumpSpeed { get; set; } public int songTimeOffset { get; set; } public long start { get; set; } - public object paused { get; set; } + public long? paused { get; set; } public int length { get; set; } public string difficulty { get; set; } public int notesCount { get; set; } @@ -79,6 +64,9 @@ public class Performance public float multiplierProgress { get; set; } public object batteryEnergy { get; set; } public bool softFailed { get; set; } + + public bool finished { get; set; } + public bool failed { get; set; } } public class Mod diff --git a/BeatRecorder/Entities/BeatSaber/SharedStatus.cs b/BeatRecorder/Entities/BeatSaber/SharedStatus.cs new file mode 100644 index 0000000..ec27da3 --- /dev/null +++ b/BeatRecorder/Entities/BeatSaber/SharedStatus.cs @@ -0,0 +1,318 @@ +using BeatRecorder.Enums; +using BeatRecorder.Util.BeatSaber; + +namespace BeatRecorder.Entities; + +public class SharedStatus +{ + public SharedStatus(HttpStatus.Status status, BaseBeatSaberHandler baseBeatSaberHandler) + { + GameInfo = new() + { + ModUsed = Mod.HttpStatus, + ModVersion = status.game?.pluginVersion, + GameVersion = status.game?.gameVersion, + }; + + Bitmap bitmap = null; + + try + { + if (!status?.beatmap?.songCover.IsNullOrWhiteSpace() ?? false) + { + bitmap = (Bitmap)Image.FromStream(new MemoryStream(Convert.FromBase64String(status?.beatmap?.songCover))); + } + else + { + + if (!baseBeatSaberHandler.ImageCache.ContainsKey("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg")) + baseBeatSaberHandler.ImageCache.TryAdd("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg", Bitmap.FromStream(new HttpClient().GetStreamAsync("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg").Result)); + + bitmap = (Bitmap)baseBeatSaberHandler.ImageCache["https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg"]; + } + } + catch { } + + this.BeatmapInfo = new() + { + Name = status.beatmap?.songName, + SubName = status.beatmap?.songSubName, + Author = status.beatmap?.songAuthorName, + Creator = status.beatmap?.levelAuthorName, + Cover = bitmap, + IdOrHash = status.beatmap?.levelId, + Bpm = status.beatmap?.songBPM, + NoteJumpSpeed = status.beatmap?.noteJumpSpeed, + Difficulty = status.beatmap?.difficulty, + BombCount = status.beatmap?.bombsCount, + NoteCount = status.beatmap?.notesCount, + WallCount = status.beatmap?.obstaclesCount + }; + + double? v = null; + + try + { + v = Math.Round((double)((status.performance?.score * 100) / status.beatmap?.maxScore), 2); + } + catch { } + + this.PerformanceInfo = new() + { + RawScore = status.performance?.rawScore, + Score = status.performance?.score, + Accuracy = v ?? 00.00, + Rank = status.performance?.rank, + MissedNoteCount = status.performance?.passedNotes, + BadCutCount = status.performance?.missedNotes, + BombHitCount = status.performance?.hitBombs, + Failed = status.performance?.failed, + Finished = status.performance?.finished, + MaxCombo = status.performance?.maxCombo, + Combo = status.performance?.combo, + SoftFailed = status.performance?.softFailed + }; + } + + public SharedStatus(BeatSaberPlus status, Game game, int MaxCombo, BaseBeatSaberHandler baseBeatSaberHandler) + { + GameInfo = game; + + Bitmap bitmap = null; + + try + { + if (!status?.mapInfoChanged?.coverRaw.IsNullOrWhiteSpace() ?? false) + { + bitmap = (Bitmap)Image.FromStream(new MemoryStream(Convert.FromBase64String(status?.mapInfoChanged?.coverRaw))); + } + else + { + + if (!baseBeatSaberHandler.ImageCache.ContainsKey("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg")) + baseBeatSaberHandler.ImageCache.TryAdd("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg", Bitmap.FromStream(new HttpClient().GetStreamAsync("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg").Result)); + + bitmap = (Bitmap)baseBeatSaberHandler.ImageCache["https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg"]; + } + } + catch { } + + this.BeatmapInfo = new() + { + Name = status.mapInfoChanged?.name, + SubName = status.mapInfoChanged?.sub_name, + Author = status.mapInfoChanged?.artist, + Creator = status.mapInfoChanged?.mapper, + Cover = bitmap, + IdOrHash = status.mapInfoChanged?.level_id, + Bpm = status.mapInfoChanged?.BPM, + Difficulty = status.mapInfoChanged?.difficulty, + }; + + double? v = null; + + try + { + v = Math.Round((status.scoreEvent?.accuracy ?? 0) * 100f, 2); + } + catch { } + + string Rank = "E"; + + if (v >= 90) + Rank = "SS"; + else if (v >= 80) + Rank = "S"; + else if (v >= 65) + Rank = "A"; + else if (v >= 50) + Rank = "B"; + else if (v >= 35) + Rank = "C"; + else if (v >= 20) + Rank = "D"; + + + + this.PerformanceInfo = new() + { + RawScore = status.scoreEvent?.score, + Score = status.scoreEvent?.score, + Accuracy = v ?? 00.00, + Rank = Rank, + MissedNoteCount = status.scoreEvent?.missCount, + Failed = false, + Finished = true, + MaxCombo = MaxCombo, + Combo = status.scoreEvent?.combo, + SoftFailed = status.scoreEvent?.currentHealth == 0f + }; + } + + public SharedStatus(DataPullerMain main, DataPullerData data, int MaxCombo, BaseBeatSaberHandler baseBeatSaberHandler) + { + GameInfo = new() + { + ModUsed = Mod.Datapuller, + ModVersion = main?.PluginVersion, + GameVersion = main?.GameVersion, + }; + + if (!baseBeatSaberHandler.ImageCache.ContainsKey(main?.coverImage ?? "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg")) + baseBeatSaberHandler.ImageCache.TryAdd(main?.coverImage ?? "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg", Bitmap.FromStream(new HttpClient().GetStreamAsync(main?.coverImage ?? "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg").Result)); + + Bitmap image = (Bitmap)baseBeatSaberHandler.ImageCache[main?.coverImage ?? "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg"]; + + BeatmapInfo = new() + { + Name = main?.SongName, + SubName = main?.SongSubName, + Author = main?.SongAuthor, + Creator = main?.Mapper, + Cover = (Bitmap)image, + IdOrHash = main?.Hash, + Bpm = main?.BPM, + NoteJumpSpeed = main?.NJS, + Difficulty = main?.Difficulty, + CustomDifficulty = main?.CustomDifficultyLabel + }; + + PerformanceInfo = new() + { + RawScore = data?.Score, + Score = data?.ScoreWithMultipliers, + Accuracy = (double)Math.Round(data?.Accuracy ?? 0, 2), + Rank = data?.Rank, + MissedNoteCount = data?.Misses, + Failed = main?.LevelFailed, + Finished = main?.LevelFinished, + MaxCombo = MaxCombo, + Combo = data?.Combo, + SoftFailed = ((main?.LevelFailed ?? false) || (data?.PlayerHealth ?? 0) <= 0) && (main?.Modifiers.noFailOn0Energy ?? false) + }; + } + + private SharedStatus() { } + + public void Update(SharedStatus newStatus) + { + if (GameInfo.ModUsed != newStatus.GameInfo.ModUsed) + throw new InvalidOperationException("Mod used cannot be updated."); + + GameInfo.ModVersion = newStatus.GameInfo.ModVersion ?? GameInfo.ModVersion; + GameInfo.GameVersion = newStatus.GameInfo.GameVersion ?? GameInfo.GameVersion; + + BeatmapInfo.Name = newStatus.BeatmapInfo.Name ?? BeatmapInfo.Name; + BeatmapInfo.SubName = newStatus.BeatmapInfo.SubName ?? BeatmapInfo.SubName; + BeatmapInfo.Author = newStatus.BeatmapInfo.Author ?? BeatmapInfo.Author; + BeatmapInfo.Creator = newStatus.BeatmapInfo.Creator ?? BeatmapInfo.Creator; + BeatmapInfo.Cover = newStatus.BeatmapInfo.Cover ?? BeatmapInfo.Cover; + BeatmapInfo.Bpm = newStatus.BeatmapInfo.Bpm ?? BeatmapInfo.Bpm; + BeatmapInfo.NoteJumpSpeed = newStatus.BeatmapInfo.NoteJumpSpeed ?? BeatmapInfo.NoteJumpSpeed; + BeatmapInfo.Difficulty = newStatus.BeatmapInfo.Difficulty ?? BeatmapInfo.Difficulty; + BeatmapInfo.NoteCount = newStatus.BeatmapInfo.NoteCount ?? BeatmapInfo.NoteCount; + BeatmapInfo.BombCount = newStatus.BeatmapInfo.BombCount ?? BeatmapInfo.BombCount; + BeatmapInfo.WallCount = newStatus.BeatmapInfo.WallCount ?? BeatmapInfo.WallCount; + BeatmapInfo.CustomDifficulty = newStatus.BeatmapInfo.CustomDifficulty ?? BeatmapInfo.CustomDifficulty; + + PerformanceInfo.Combo = newStatus.PerformanceInfo.Combo ?? PerformanceInfo.Combo; + PerformanceInfo.RawScore = newStatus.PerformanceInfo.RawScore ?? PerformanceInfo.RawScore; + PerformanceInfo.Score = newStatus.PerformanceInfo.Score ?? PerformanceInfo.Score; + PerformanceInfo.Accuracy = newStatus.PerformanceInfo.Accuracy ?? PerformanceInfo.Accuracy; + PerformanceInfo.Rank = newStatus.PerformanceInfo.Rank ?? PerformanceInfo.Rank; + PerformanceInfo.MissedNoteCount = newStatus.PerformanceInfo.MissedNoteCount ?? PerformanceInfo.MissedNoteCount; + PerformanceInfo.BadCutCount = newStatus.PerformanceInfo.BadCutCount ?? PerformanceInfo.BadCutCount; + PerformanceInfo.BombHitCount = newStatus.PerformanceInfo.BombHitCount ?? PerformanceInfo.BombHitCount; + PerformanceInfo.MaxCombo = newStatus.PerformanceInfo.MaxCombo ?? PerformanceInfo.MaxCombo; + PerformanceInfo.SoftFailed = newStatus.PerformanceInfo.SoftFailed ?? PerformanceInfo.SoftFailed; + PerformanceInfo.Failed = newStatus.PerformanceInfo.Failed ?? PerformanceInfo.Failed; + PerformanceInfo.Finished = newStatus.PerformanceInfo.Finished ?? PerformanceInfo.Finished; + } + + public SharedStatus Clone() + { + var newStatus = new SharedStatus(); + + newStatus.GameInfo = new(); + newStatus.GameInfo.ModUsed = GameInfo.ModUsed; + newStatus.GameInfo.ModVersion = GameInfo.ModVersion; + newStatus.GameInfo.GameVersion = GameInfo.GameVersion; + + newStatus.BeatmapInfo = new(); + newStatus.BeatmapInfo.Name = BeatmapInfo.Name; + newStatus.BeatmapInfo.SubName = BeatmapInfo.SubName; + newStatus.BeatmapInfo.Author = BeatmapInfo.Author; + newStatus.BeatmapInfo.Creator = BeatmapInfo.Creator; + newStatus.BeatmapInfo.Cover = BeatmapInfo.Cover; + newStatus.BeatmapInfo.Bpm = BeatmapInfo.Bpm; + newStatus.BeatmapInfo.NoteJumpSpeed = BeatmapInfo.NoteJumpSpeed; + newStatus.BeatmapInfo.Difficulty = BeatmapInfo.Difficulty; + newStatus.BeatmapInfo.NoteCount = BeatmapInfo.NoteCount; + newStatus.BeatmapInfo.BombCount = BeatmapInfo.BombCount; + newStatus.BeatmapInfo.WallCount = BeatmapInfo.WallCount; + newStatus.BeatmapInfo.CustomDifficulty = BeatmapInfo.CustomDifficulty; + + newStatus.PerformanceInfo = new(); + newStatus.PerformanceInfo.RawScore = PerformanceInfo.RawScore; + newStatus.PerformanceInfo.Score = PerformanceInfo.Score; + newStatus.PerformanceInfo.Accuracy = PerformanceInfo.Accuracy; + newStatus.PerformanceInfo.Rank = PerformanceInfo.Rank; + newStatus.PerformanceInfo.MissedNoteCount = PerformanceInfo.MissedNoteCount; + newStatus.PerformanceInfo.BadCutCount = PerformanceInfo.BadCutCount; + newStatus.PerformanceInfo.BombHitCount = PerformanceInfo.BombHitCount; + newStatus.PerformanceInfo.MaxCombo = PerformanceInfo.MaxCombo; + newStatus.PerformanceInfo.SoftFailed = PerformanceInfo.SoftFailed; + newStatus.PerformanceInfo.Failed = PerformanceInfo.Failed; + newStatus.PerformanceInfo.Finished = PerformanceInfo.Finished; + return newStatus; + } + + public Game GameInfo { get; set; } + public Beatmap BeatmapInfo { get; set; } + + public Performance PerformanceInfo { get; set; } + + public class Game + { + public Mod ModUsed { get; set; } + public string ModVersion { get; set; } + public string GameVersion { get; set; } + } + + public class Beatmap + { + public string Name { get; set; } + public string SubName { get; set; } + public string NameWithSub => $"{Name}{(SubName.IsNullOrWhiteSpace() ? "" : $" {SubName}")}"; + public string Author { get; set; } + public string Creator { get; set; } + public Bitmap Cover { get; set; } + public string IdOrHash { get; set; } + public float? Bpm { get; set; } + public float? NoteJumpSpeed { get; set; } + public string Difficulty { get; set; } + public string CustomDifficulty { get => (_CustomDifficulty.IsNullOrWhiteSpace() ? Difficulty : _CustomDifficulty); set { _CustomDifficulty = value; } } + public long? NoteCount { get; set; } + public long? BombCount { get; set; } + public long? WallCount { get; set; } + + private string _CustomDifficulty { get; set; } + } + + public class Performance + { + public long? RawScore { get; set; } + public long? Score { get; set; } + public double? Accuracy { get; set; } + public string Rank { get; set; } + public long? MissedNoteCount { get; set; } = 0; + public long? BadCutCount { get; set; } = 0; + public long? BombHitCount { get; set; } = 0; + public long? CombinedMisses { get => MissedNoteCount + BadCutCount + BombHitCount; } + public long? Combo { get; set; } + public long? MaxCombo { get; set; } + public bool? SoftFailed { get; set; } + public bool? Failed { get; set; } + public bool? Finished { get; set; } + } +} diff --git a/BeatRecorder/Entities/Settings.cs b/BeatRecorder/Entities/Config.cs similarity index 87% rename from BeatRecorder/Entities/Settings.cs rename to BeatRecorder/Entities/Config.cs index 34eb46f..55b6349 100644 --- a/BeatRecorder/Entities/Settings.cs +++ b/BeatRecorder/Entities/Config.cs @@ -1,6 +1,8 @@ -namespace BeatRecorder.Entities; +using Xorog.Logger.Enums; -internal class Settings +namespace BeatRecorder.Entities; + +internal class Config { ///

/// Instructions to go to the repository for help @@ -15,7 +17,7 @@ internal class Settings /// /// What mod to connect to /// - public string Mod { get; set; } = "http-status"; + public string Mod { get; set; } = "datapuller"; /// /// Whether to display the GUI @@ -58,9 +60,14 @@ internal class Settings public string OBSUrl { get; set; } = "127.0.0.1"; /// - /// The port of the websocket for obs + /// The port of the websocket server for a legacy version obs-websocket + /// + public string OBSPortLegacy { get; set; } = "4444"; + + /// + /// The port of the websocket server for v5.0+ versions of obs-websocket /// - public string OBSPort { get; set; } = "4444"; + public string OBSPortModern { get; set; } = "4455"; /// /// The password for the obs websocket @@ -132,4 +139,10 @@ internal class Settings /// public string OBSPauseScene { get; set; } = ""; + + + /// + /// Migration value for deserializering + /// + public string OBSPort { set { OBSPortLegacy = value; } } } diff --git a/BeatRecorder/Entities/Steam/NotificationEntry.cs b/BeatRecorder/Entities/NotificationEntry.cs similarity index 60% rename from BeatRecorder/Entities/Steam/NotificationEntry.cs rename to BeatRecorder/Entities/NotificationEntry.cs index 9df9340..0185d93 100644 --- a/BeatRecorder/Entities/Steam/NotificationEntry.cs +++ b/BeatRecorder/Entities/NotificationEntry.cs @@ -1,8 +1,10 @@ -namespace BeatRecorder.Entities; +using BeatRecorder.Enums; -public class NotificationEntry +namespace BeatRecorder.Entities; + +internal class NotificationEntry { public string Message { get; set; } public int Delay { get; set; } = 2000; public MessageType Type { get; set; } = MessageType.INFO; -} \ No newline at end of file +} diff --git a/BeatRecorder/Entities/OBS/Event.cs b/BeatRecorder/Entities/OBS/Event.cs new file mode 100644 index 0000000..7dca313 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Event.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS; + +internal class Event +{ + public int op { get; set; } + public D d { get; set; } + + public class D + { + public string eventType { get; set; } + public int eventIntent { get; set; } + public JObject eventData { get; set; } + } +} diff --git a/BeatRecorder/Entities/OBS/Events/EventType.cs b/BeatRecorder/Entities/OBS/Events/EventType.cs new file mode 100644 index 0000000..a293785 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Events/EventType.cs @@ -0,0 +1,12 @@ +namespace BeatRecorder.Entities.OBS; + +internal class EventType +{ + public int op { get; set; } + public D d { get; set; } + + public class D + { + public string eventType { get; set; } + } +} diff --git a/BeatRecorder/Entities/OBS/Events/RecordStateChanged.cs b/BeatRecorder/Entities/OBS/Events/RecordStateChanged.cs new file mode 100644 index 0000000..4947f81 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Events/RecordStateChanged.cs @@ -0,0 +1,21 @@ +namespace BeatRecorder.Entities.OBS; +internal class RecordStateChanged +{ + public int op { get; set; } + public D d { get; set; } + + public class D + { + public Eventdata eventData { get; set; } + public int eventIntent { get; set; } + public string eventType { get; set; } + } + + public class Eventdata + { + public bool outputActive { get; set; } + public string outputPath { get; set; } + public string outputState { get; set; } + } + +} diff --git a/BeatRecorder/Entities/OBS/Indentified.cs b/BeatRecorder/Entities/OBS/Indentified.cs new file mode 100644 index 0000000..9a7ef02 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Indentified.cs @@ -0,0 +1,13 @@ +namespace BeatRecorder.Entities.OBS; + +internal class Indentified +{ + public int op { get; set; } + public D d { get; set; } + + public class D + { + public int negotiatedRpcVersion { get; set; } + } + +} diff --git a/BeatRecorder/Entities/OBS/Indentify.cs b/BeatRecorder/Entities/OBS/Indentify.cs new file mode 100644 index 0000000..cb85465 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Indentify.cs @@ -0,0 +1,21 @@ +namespace BeatRecorder.Entities.OBS; + +internal class Hello +{ + public D d { get; set; } + public int op { get; set; } + + public class D + { + public Authentication authentication { get; set; } + public string obsWebSocketVersion { get; set; } + public int rpcVersion { get; set; } + } + + public class Authentication + { + public string challenge { get; set; } + public string salt { get; set; } + } + +} diff --git a/BeatRecorder/Entities/OBS/AuthenticationRequired.cs b/BeatRecorder/Entities/OBS/Legacy/AuthenticationRequired.cs similarity index 70% rename from BeatRecorder/Entities/OBS/AuthenticationRequired.cs rename to BeatRecorder/Entities/OBS/Legacy/AuthenticationRequired.cs index ce4a458..233c86e 100644 --- a/BeatRecorder/Entities/OBS/AuthenticationRequired.cs +++ b/BeatRecorder/Entities/OBS/Legacy/AuthenticationRequired.cs @@ -1,10 +1,10 @@ -namespace BeatRecorder.Entities; +namespace BeatRecorder.Entities.OBS.Legacy; -public class AuthenticationRequired +internal class AuthenticationRequired { public bool authRequired { get; set; } public string challenge { get; set; } public string messageid { get; set; } public string salt { get; set; } public string status { get; set; } -} \ No newline at end of file +} diff --git a/BeatRecorder/Entities/OBS/Legacy/ObsResponse.cs b/BeatRecorder/Entities/OBS/Legacy/ObsResponse.cs new file mode 100644 index 0000000..f68c987 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/ObsResponse.cs @@ -0,0 +1,13 @@ +namespace BeatRecorder.Entities.OBS.Legacy; + +internal class ObsResponse +{ + [JsonProperty("message-id")] + public string MessageId { get; set; } + + [JsonProperty("update-type")] + public string UpdateType { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } +} diff --git a/BeatRecorder/Entities/OBS/RecordingStopped.cs b/BeatRecorder/Entities/OBS/Legacy/RecordingStopped.cs similarity index 81% rename from BeatRecorder/Entities/OBS/RecordingStopped.cs rename to BeatRecorder/Entities/OBS/Legacy/RecordingStopped.cs index 80abf80..0dd808a 100644 --- a/BeatRecorder/Entities/OBS/RecordingStopped.cs +++ b/BeatRecorder/Entities/OBS/Legacy/RecordingStopped.cs @@ -1,4 +1,4 @@ -namespace BeatRecorder.Entities; +namespace BeatRecorder.Entities.OBS.Legacy; public class RecordingStopped { diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/AuthenticateRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/AuthenticateRequest.cs new file mode 100644 index 0000000..4271149 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/AuthenticateRequest.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS.Legacy; + +internal class AuthenticateRequest : BaseRequest +{ + internal AuthenticateRequest(string auth, string id = null) + { + this.Auth = auth; + this.RequestType = "Authenticate"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } + + [JsonProperty("auth")] + internal string Auth { get; set; } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/BaseRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/BaseRequest.cs new file mode 100644 index 0000000..e4ebcaa --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/BaseRequest.cs @@ -0,0 +1,12 @@ +namespace BeatRecorder.Entities.OBS.Legacy; + +internal abstract class BaseRequest +{ + [JsonProperty("request-type")] + public string RequestType { get; set; } + + [JsonProperty("message-id")] + public string MessageId { get; set; } + + internal string Build() => JsonConvert.SerializeObject(this); +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/GetAuthRequiredRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/GetAuthRequiredRequest.cs new file mode 100644 index 0000000..0424c07 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/GetAuthRequiredRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS.Legacy; +internal class GetAuthRequiredRequest : BaseRequest +{ + internal GetAuthRequiredRequest(string id = null) + { + this.RequestType = "GetAuthRequired"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/PauseRecordingRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/PauseRecordingRequest.cs new file mode 100644 index 0000000..8d63263 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/PauseRecordingRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS.Legacy; +internal class PauseRecordingRequest : BaseRequest +{ + internal PauseRecordingRequest(string id = null) + { + this.RequestType = "PauseRecording"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/ResumeRecordingRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/ResumeRecordingRequest.cs new file mode 100644 index 0000000..1a9cb06 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/ResumeRecordingRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS.Legacy; +internal class ResumeRecordingRequest : BaseRequest +{ + internal ResumeRecordingRequest(string id = null) + { + this.RequestType = "ResumeRecording"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/SetCurrentSceneRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/SetCurrentSceneRequest.cs new file mode 100644 index 0000000..5fc5e58 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/SetCurrentSceneRequest.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS.Legacy; + +internal class SetCurrentScene : BaseRequest +{ + internal SetCurrentScene(string SceneName, string id = null) + { + this.SceneName = SceneName; + this.RequestType = "SetCurrentScene"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } + + [JsonProperty("scene-name")] + public string SceneName { get; set; } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/StartRecordingRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/StartRecordingRequest.cs new file mode 100644 index 0000000..bd1bd0e --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/StartRecordingRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS.Legacy; +internal class StartRecordingRequest : BaseRequest +{ + internal StartRecordingRequest(string id = null) + { + this.RequestType = "StartRecording"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } +} diff --git a/BeatRecorder/Entities/OBS/Legacy/Requests/StopRecordingRequest.cs b/BeatRecorder/Entities/OBS/Legacy/Requests/StopRecordingRequest.cs new file mode 100644 index 0000000..32a87b3 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Legacy/Requests/StopRecordingRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS.Legacy; +internal class StopRecordingRequest : BaseRequest +{ + internal StopRecordingRequest(string id = null) + { + this.RequestType = "StopRecording"; + this.MessageId = id ?? Guid.NewGuid().ToString(); + } +} diff --git a/BeatRecorder/Entities/OBS/OBSWebSocketStatus.cs b/BeatRecorder/Entities/OBS/OBSWebSocketStatus.cs deleted file mode 100644 index 696d06a..0000000 --- a/BeatRecorder/Entities/OBS/OBSWebSocketStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace BeatRecorder; - -class OBSWebSocketStatus -{ - internal static bool OBSRecording = false; - internal static bool OBSRecordingPaused = false; - internal static int RecordingSeconds = 0; - - internal static CancellationTokenSource CancelStopRecordingDelay { get; set; } -} diff --git a/BeatRecorder/Entities/OBS/ObsResponse.cs b/BeatRecorder/Entities/OBS/ObsResponse.cs new file mode 100644 index 0000000..dba8aa2 --- /dev/null +++ b/BeatRecorder/Entities/OBS/ObsResponse.cs @@ -0,0 +1,7 @@ +namespace BeatRecorder.Entities.OBS; + +internal class ObsResponse +{ + public int op { get; set; } + public object d { get; set; } +} diff --git a/BeatRecorder/Entities/OBS/RecordingStatus.cs b/BeatRecorder/Entities/OBS/RecordingStatus.cs deleted file mode 100644 index 4b6458b..0000000 --- a/BeatRecorder/Entities/OBS/RecordingStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BeatRecorder.Entities; - -public class RecordingStatus -{ - public bool isRecording { get; set; } - public bool isRecordingPaused { get; set; } -} \ No newline at end of file diff --git a/BeatRecorder/Entities/OBS/Requests/AuthenticateRequest.cs b/BeatRecorder/Entities/OBS/Requests/AuthenticateRequest.cs new file mode 100644 index 0000000..734e931 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/AuthenticateRequest.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; + +namespace BeatRecorder.Entities.OBS; + +internal class Indentify : BaseRequest +{ + internal Indentify(string auth = "", int rpcVersion = 1) + { + this.op = 1; + this.d = new JObject + { + ["rpcVersion"] = rpcVersion, + }; + + if (!auth.IsNullOrWhiteSpace()) + ((JObject)this.d).Add("authentication", auth); + } +} diff --git a/BeatRecorder/Entities/OBS/Requests/BaseRequest.cs b/BeatRecorder/Entities/OBS/Requests/BaseRequest.cs new file mode 100644 index 0000000..768a199 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/BaseRequest.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Entities.OBS; + +internal abstract class BaseRequest +{ + public int op { get; set; } + public object d { get; set; } + + internal string Build() => JsonConvert.SerializeObject(this); +} diff --git a/BeatRecorder/Entities/OBS/Requests/PauseRecord.cs b/BeatRecorder/Entities/OBS/Requests/PauseRecord.cs new file mode 100644 index 0000000..bc025dd --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/PauseRecord.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS; + +internal class PauseRecord : BaseRequest +{ + internal PauseRecord() + { + this.op = 6; + this.d = new JObject + { + ["requestType"] = "PauseRecord", + ["requestId"] = Guid.NewGuid().ToString(), + }; + } +} diff --git a/BeatRecorder/Entities/OBS/Requests/ResumeRecord.cs b/BeatRecorder/Entities/OBS/Requests/ResumeRecord.cs new file mode 100644 index 0000000..a17c4d0 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/ResumeRecord.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS; + +internal class ResumeRecord : BaseRequest +{ + internal ResumeRecord() + { + this.op = 6; + this.d = new JObject + { + ["requestType"] = "ResumeRecord", + ["requestId"] = Guid.NewGuid().ToString(), + }; + } +} diff --git a/BeatRecorder/Entities/OBS/Requests/SetCurrentProgramScene.cs b/BeatRecorder/Entities/OBS/Requests/SetCurrentProgramScene.cs new file mode 100644 index 0000000..623fe1c --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/SetCurrentProgramScene.cs @@ -0,0 +1,18 @@ +namespace BeatRecorder.Entities.OBS; + +internal class SetCurrentProgramScene : BaseRequest +{ + internal SetCurrentProgramScene(string scene) + { + this.op = 6; + this.d = new JObject + { + ["requestType"] = "SetCurrentProgramScene", + ["requestId"] = Guid.NewGuid().ToString(), + ["requestData"] = new JObject + { + ["sceneName"] = scene + } + }; + } +} diff --git a/BeatRecorder/Entities/OBS/Requests/StartRecord.cs b/BeatRecorder/Entities/OBS/Requests/StartRecord.cs new file mode 100644 index 0000000..294cbe8 --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/StartRecord.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS; + +internal class StartRecord : BaseRequest +{ + internal StartRecord() + { + this.op = 6; + this.d = new JObject + { + ["requestType"] = "StartRecord", + ["requestId"] = Guid.NewGuid().ToString(), + }; + } +} diff --git a/BeatRecorder/Entities/OBS/Requests/StopRecord.cs b/BeatRecorder/Entities/OBS/Requests/StopRecord.cs new file mode 100644 index 0000000..af5ffed --- /dev/null +++ b/BeatRecorder/Entities/OBS/Requests/StopRecord.cs @@ -0,0 +1,14 @@ +namespace BeatRecorder.Entities.OBS; + +internal class StopRecord : BaseRequest +{ + internal StopRecord() + { + this.op = 6; + this.d = new JObject + { + ["requestType"] = "StopRecord", + ["requestId"] = Guid.NewGuid().ToString(), + }; + } +} diff --git a/BeatRecorder/Entities/Status.cs b/BeatRecorder/Entities/Status.cs new file mode 100644 index 0000000..1134a62 --- /dev/null +++ b/BeatRecorder/Entities/Status.cs @@ -0,0 +1,6 @@ +namespace BeatRecorder.Entities; + +internal class Status +{ + +} diff --git a/BeatRecorder/Enums/ConnectionTypeWarning.cs b/BeatRecorder/Enums/ConnectionTypeWarning.cs index 2c2619b..44c3252 100644 --- a/BeatRecorder/Enums/ConnectionTypeWarning.cs +++ b/BeatRecorder/Enums/ConnectionTypeWarning.cs @@ -2,9 +2,9 @@ public enum ConnectionTypeWarning { - CONNECTED, - MOD_INSTALLED, - MOD_NOT_INSTALLED, - NOT_MODDED, - NO_PROCESS + Connected, + ModInstalled, + ModNotInstalled, + NotModded, + NoProcess } \ No newline at end of file diff --git a/BeatRecorder/Enums/GameEnvironment.cs b/BeatRecorder/Enums/GameEnvironment.cs new file mode 100644 index 0000000..10fa3d8 --- /dev/null +++ b/BeatRecorder/Enums/GameEnvironment.cs @@ -0,0 +1,9 @@ +namespace BeatRecorder.Enums; + +public enum GameEnvironment +{ + Menu, + InLevel, + Paused, + Unknown +} diff --git a/BeatRecorder/Enums/Mod.cs b/BeatRecorder/Enums/Mod.cs new file mode 100644 index 0000000..f95115b --- /dev/null +++ b/BeatRecorder/Enums/Mod.cs @@ -0,0 +1,8 @@ +namespace BeatRecorder.Enums; + +public enum Mod +{ + HttpStatus, + Datapuller, + BeatSaberPlus +} diff --git a/BeatRecorder/Global.cs b/BeatRecorder/Global.cs index 4638412..948741d 100644 --- a/BeatRecorder/Global.cs +++ b/BeatRecorder/Global.cs @@ -1,4 +1,5 @@ global using Newtonsoft.Json; +global using Newtonsoft.Json.Linq; global using Octokit; global using System; global using System.Diagnostics; @@ -15,19 +16,16 @@ global using System.Collections.Generic; global using System.Drawing; global using System.Drawing.Imaging; -global using System.Windows.Forms; - -global using BeatRecorder.Entities; -global using BeatRecorder.Enums; -global using BeatRecorder.Util; +global using Xorog.UniversalExtensions; global using Xorog.Logger; +global using Xorog.Logger.Enums; global using static Xorog.Logger.Logger; -global using static Xorog.Logger.LoggerObjects; +global using static BeatRecorder.Log; global using System.Runtime.InteropServices; -global using BeatRecorderUI; global using BeatRecorder; global using Valve.VR; global using Websocket.Client.Models; -global using System.ComponentModel; \ No newline at end of file +global using System.ComponentModel; + diff --git a/BeatRecorder/HttpStatusWebSocket/HttpStatus.cs b/BeatRecorder/HttpStatusWebSocket/HttpStatus.cs deleted file mode 100644 index a37ed1a..0000000 --- a/BeatRecorder/HttpStatusWebSocket/HttpStatus.cs +++ /dev/null @@ -1,492 +0,0 @@ -namespace BeatRecorder; - -class HttpStatus -{ - internal static void MessageReceived(string e) - { - HttpStatusStatus.BeatSaberEvent _status; - - try - { - _status = JsonConvert.DeserializeObject(e); - } - catch (Exception ex) - { - LogFatal($"[BS-HS] Unable to convert beatsaber-http-status message into an dictionary: {ex}"); - return; - } - - switch (_status.@event) - { - case "hello": - - try { HttpStatusStatus.HttpStatusCurrentBeatmap = _status.status.beatmap; } catch { } - try { HttpStatusStatus.HttpStatusCurrentPerformance = _status.status.performance; } catch { } - - try - { - if (Program.LoadedSettings.OBSMenuScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSMenuScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - - LogInfo("[BS-HS] Connected."); - break; - - case "songStart": - LogDebug("[BS-HS] Song started."); - LogInfo($"[BS-HS] Started playing \"{_status.status.beatmap.songName}\" by \"{_status.status.beatmap.songAuthorName}\""); - - HttpStatusStatus.FailedCurrentSong = false; - HttpStatusStatus.FinishedCurrentSong = false; - HttpStatusStatus.HttpStatusCurrentBeatmap = _status.status.beatmap; - HttpStatusStatus.HttpStatusCurrentPerformance = _status.status.performance; - - try - { - if (Program.LoadedSettings.OBSIngameScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSIngameScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - - try - { - _ = OBSWebSocket.StartRecording(); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "finished": - LogInfo("[BS-HS] Song finished."); - - HttpStatusStatus.HttpStatusCurrentPerformance = _status.status.performance; - HttpStatusStatus.HttpStatusLastPerformance = HttpStatusStatus.HttpStatusCurrentPerformance; - HttpStatusStatus.FinishedCurrentSong = true; - - try - { - if (Program.LoadedSettings.OBSMenuScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSMenuScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "failed": - LogInfo("[BS-HS] Song failed."); - - HttpStatusStatus.HttpStatusCurrentPerformance = _status.status.performance; - HttpStatusStatus.HttpStatusLastPerformance = HttpStatusStatus.HttpStatusCurrentPerformance; - HttpStatusStatus.FailedCurrentSong = true; - - try - { - if (Program.LoadedSettings.OBSMenuScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSMenuScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "pause": - LogInfo("[BS-HS] Song paused."); - - try - { - if (Program.LoadedSettings.PauseRecordingOnIngamePause) - if (Program.obsWebSocket.IsStarted) - Program.obsWebSocket.Send($"{{\"request-type\":\"PauseRecording\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSPauseScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSPauseScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "resume": - LogInfo("[BS-HS] Song resumed."); - - try - { - if (Program.LoadedSettings.PauseRecordingOnIngamePause) - if (Program.obsWebSocket.IsStarted) - Program.obsWebSocket.Send($"{{\"request-type\":\"ResumeRecording\", \"message-id\":\"ResumeRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSIngameScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSIngameScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "menu": - LogDebug("[BS-HS] Menu entered."); - LogInfo($"[BS-HS] Stopped playing \"{_status?.status?.beatmap?.songName}\" by \"{_status?.status?.beatmap?.songAuthorName}\""); - - try - { - HttpStatusStatus.HttpStatusLastPerformance = HttpStatusStatus.HttpStatusCurrentPerformance; - HttpStatusStatus.HttpStatusLastBeatmap = HttpStatusStatus.HttpStatusCurrentBeatmap; - - HttpStatusStatus.FinishedLastSong = HttpStatusStatus.FinishedCurrentSong; - HttpStatusStatus.FailedLastSong = HttpStatusStatus.FailedCurrentSong; - _ = OBSWebSocket.StopRecording(OBSWebSocketStatus.CancelStopRecordingDelay.Token); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - - try - { - if (Program.LoadedSettings.OBSMenuScene != "") - Program.obsWebSocket.Send($"{{\"request-type\":\"SetCurrentScene\", \"scene-name\":\"{Program.LoadedSettings.OBSMenuScene}\", \"message-id\":\"PauseRecording\"}}"); - } - catch (Exception ex) - { - LogError($"[BS-HS] {ex}"); - return; - } - break; - - case "scoreChanged": - HttpStatusStatus.HttpStatusCurrentPerformance = _status.status.performance; - break; - } - } - - internal static void Reconnected(ReconnectionInfo msg) - { - if (msg.Type != ReconnectionType.Initial) - LogWarn($"[BS-HS] Reconnected: {msg.Type}"); - - Objects.LastHttpStatusWarning = ConnectionTypeWarning.CONNECTED; - Program.SendNotification("Connected to Beat Saber", 1000, MessageType.INFO); - } - - internal static void Disconnected(DisconnectionInfo msg) - { - try - { - Process[] processCollection = Process.GetProcesses(); - - if (!processCollection.Any(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber"))) - { - if (Objects.LastHttpStatusWarning != ConnectionTypeWarning.NO_PROCESS) - { - LogWarn($"[BS-HS] Couldn't find a BeatSaber process, is BeatSaber started? ({msg.Type})"); - Program.SendNotification("Couldn't connect to BeatSaber, is it even running?", 5000, MessageType.ERROR); - } - Objects.LastHttpStatusWarning = ConnectionTypeWarning.NO_PROCESS; - } - else - { - bool FoundWebSocketDll = false; - - string InstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber")).MainModule.FileName; - InstallationDirectory = InstallationDirectory.Remove(InstallationDirectory.LastIndexOf("\\"), InstallationDirectory.Length - InstallationDirectory.LastIndexOf("\\")); - - if (Directory.GetDirectories(InstallationDirectory).Any(x => x.ToLower().EndsWith("plugins"))) - { - if (Directory.GetFiles($"{InstallationDirectory}\\Plugins").Any(x => x.Contains("HTTPStatus") && x.EndsWith(".dll"))) - { - FoundWebSocketDll = true; - } - } - else - { - if (Objects.LastHttpStatusWarning != ConnectionTypeWarning.NOT_MODDED) - { - LogFatal($"[BS-HS] Beat Saber seems to be running but the beatsaber-http-status modifaction doesn't seem to be installed. Is your game even modded? (If haven't modded it, please do it: https://bit.ly/2TAvenk. If already modded, install beatsaber-http-status: https://bit.ly/3wYX3Dd) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Have you modded your game?", 10000, MessageType.ERROR); - } - Objects.LastHttpStatusWarning = ConnectionTypeWarning.NOT_MODDED; - } - - if (FoundWebSocketDll) - { - if (Objects.LastHttpStatusWarning != ConnectionTypeWarning.MOD_INSTALLED) - { - LogFatal($"[BS-HS] Beat Saber seems to be running and the beatsaber-http-status modifaction seems to be installed. Please make sure you put in the right port and you installed all of beatsaber-http-status' dependiencies! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Please make sure you selected the right port.", 10000, MessageType.ERROR); - } - Objects.LastHttpStatusWarning = ConnectionTypeWarning.MOD_INSTALLED; - } - else - { - if (Objects.LastHttpStatusWarning != ConnectionTypeWarning.MOD_NOT_INSTALLED) - { - LogFatal($"[BS-HS] Beat Saber seems to be running but the beatsaber-http-status modifaction doesn't seem to be installed. Please make sure to install beatsaber-http-status! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); - Program.SendNotification("Couldn't connect to Beat Saber. Please make sure DataPuller is installed.", 10000, MessageType.ERROR); - } - Objects.LastHttpStatusWarning = ConnectionTypeWarning.MOD_NOT_INSTALLED; - } - } - } - catch (Exception ex) - { - LogError($"[BS-HS] Failed to check if beatsaber-http-status is installed: (Disconnect Reason: {msg.Type}) {ex}"); - } - } - - internal static void HandleFile(HttpStatusStatus.Beatmap BeatmapInfo, HttpStatusStatus.Performance PerformanceInfo, string OldFileName, bool FinishedLast, bool FailedLast) - { - if (BeatmapInfo != null) - { - bool DeleteFile = false; - string NewName = Program.LoadedSettings.FileFormat; - - if (PerformanceInfo != null) - { - // Generate FileName-based on Config File - - if (NewName.Contains("")) - NewName = NewName.Replace("", PerformanceInfo.rank); - - if (NewName.Contains("")) - { - string GeneratedAccuracy = ""; - - if (PerformanceInfo.softFailed) - { - if (Program.LoadedSettings.DeleteSoftFailed) - { - LogDebug($"[BR] Soft-Failed. Deletion requested."); - DeleteFile = true; - } - - GeneratedAccuracy = $"NF-"; - } - - if (FinishedLast) - GeneratedAccuracy += $"{Math.Round((float)(((float)PerformanceInfo.score * (float)100) / (float)BeatmapInfo.maxScore), 2)}"; - else - { - if (Program.LoadedSettings.DeleteQuit) - { - LogDebug($"[BR] Quit. Deletion requested."); - DeleteFile = true; - - if (GeneratedAccuracy == "NF-") - if (!Program.LoadedSettings.DeleteIfQuitAfterSoftFailed) - { - LogDebug($"[BR] Soft-Failed but quit, deletion request reverted."); - DeleteFile = false; - } - } - - GeneratedAccuracy += $"QUIT"; - } - - if (FailedLast) - { - if (Program.LoadedSettings.DeleteFailed) - { - LogDebug($"[BR] Failed. Deletion requested."); - DeleteFile = true; - } - else - DeleteFile = false; - - GeneratedAccuracy = $"FAILED"; - } - - NewName = NewName.Replace("", GeneratedAccuracy); - } - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.maxCombo}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.score}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.rawScore}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{PerformanceInfo.missedNotes}"); - } - else - { - // Generate FileName-based on Config File (but without performance stats) - - if (NewName.Contains("")) - NewName = NewName.Replace("", "Z"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", "00.00"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"0"); - } - - if (Program.LoadedSettings.DeleteIfShorterThan > OBSWebSocketStatus.RecordingSeconds) - { - LogDebug($"[BR] The recording is too short. Deletion requested."); - DeleteFile = true; - } - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.songName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", $"{BeatmapInfo.songName}{(!string.IsNullOrWhiteSpace(BeatmapInfo.songSubName) ? $" {BeatmapInfo.songSubName}" : "")}"); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.songAuthorName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.songSubName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.levelAuthorName); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.levelId); - - if (NewName.Contains("")) - NewName = NewName.Replace("", BeatmapInfo.songBPM.ToString()); - - if (NewName.Contains("")) - { - switch (BeatmapInfo.difficulty.ToLower()) - { - case "expertplus": - NewName = NewName.Replace("", "Expert+"); - break; - default: - NewName = NewName.Replace("", BeatmapInfo.difficulty); - break; - } - } - - if (NewName.Contains("")) - { - switch (BeatmapInfo.difficulty.ToLower()) - { - case "expert": - NewName = NewName.Replace("", "EX"); - break; - case "expert+": - case "expertplus": - NewName = NewName.Replace("", "EX+"); - break; - default: - NewName = NewName.Replace("", BeatmapInfo.difficulty.Remove(1, BeatmapInfo.difficulty.Length - 1)); - break; - } - } - - if (File.Exists($"{OldFileName}")) - { - - string FileExist = ""; - - FileInfo fileInfo = new(OldFileName); - - while (File.Exists($"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{fileInfo.Extension}")) - { - FileExist += "_"; - } - - foreach (char b in Path.GetInvalidFileNameChars()) - { - NewName = NewName.Replace(b, '_'); - } - - string FileExists = ""; - int FileExistsCount = 2; - - string NewFileName = $"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{FileExists}{fileInfo.Extension}"; - - while (File.Exists(NewFileName)) - { - FileExist = $" ({FileExistsCount})"; - NewFileName = $"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{FileExists}{fileInfo.Extension}"; - FileExistsCount++; - } - - try - { - if (!DeleteFile) - { - LogInfo($"[BR] Renaming \"{fileInfo.Name}\" to \"{NewName}{FileExists}{fileInfo.Extension}\".."); - File.Move(OldFileName, NewFileName); - LogInfo($"[BR] Successfully renamed."); - Program.SendNotification("Recording renamed.", 1000, MessageType.INFO); - } - else - { - LogInfo($"[BR] Deleting \"{fileInfo.Name}\".."); - File.Delete(OldFileName); - LogInfo($"[BR] Successfully deleted."); - Program.SendNotification("Recording deleted.", 1000, MessageType.INFO); - } - } - catch (Exception ex) - { - LogError($"[BR] {ex}."); - } - } - else - { - LogError($"[BR] {OldFileName} doesn't exist."); - } - } - else - { - LogError($"[BR] Last recorded file can't be renamed."); - } - } -} diff --git a/BeatRecorder/OBSWebSocket/OBSWebSocket.cs b/BeatRecorder/OBSWebSocket/OBSWebSocket.cs deleted file mode 100644 index 85473f0..0000000 --- a/BeatRecorder/OBSWebSocket/OBSWebSocket.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace BeatRecorder; - -class OBSWebSocket -{ - internal static async Task StartRecording() - { - if (!Program.LoadedSettings.AutomaticRecording) - return; - - if (Program.obsWebSocket.IsStarted) - { - if (OBSWebSocketStatus.OBSRecording) - { - OBSWebSocketStatus.CancelStopRecordingDelay.Cancel(); - await StopRecording(OBSWebSocketStatus.CancelStopRecordingDelay.Token, true); - } - - OBSWebSocketStatus.CancelStopRecordingDelay = new CancellationTokenSource(); - - while (OBSWebSocketStatus.OBSRecording) - { - Thread.Sleep(20); - } - - if (Program.LoadedSettings.MininumWaitUntilRecordingCanStart > 199 || Program.LoadedSettings.MininumWaitUntilRecordingCanStart < 2001) - Thread.Sleep(Program.LoadedSettings.MininumWaitUntilRecordingCanStart); - else - { - LogError("The MininumWaitUntilRecordingCanStart has to be between 200ms and 2000ms. Defaulting to a wait time of 800ms."); - Thread.Sleep(800); - } - - Program.obsWebSocket.Send($"{{\"request-type\":\"StartRecording\", \"message-id\":\"StartRecording\"}}"); - } - else - { - LogError("[OBS] The WebSocket isn't connected, no recording can be started."); - } - } - - internal static async Task StopRecording(CancellationToken CancelToken, bool ForceStop = false) - { - if (!Program.LoadedSettings.AutomaticRecording) - return; - - if (Program.obsWebSocket.IsStarted) - { - if (OBSWebSocketStatus.OBSRecording) - { - if (!ForceStop) - { - if (Program.LoadedSettings.StopRecordingDelay > 0 && Program.LoadedSettings.StopRecordingDelay < 21) - { - try - { - await Task.Delay(Program.LoadedSettings.StopRecordingDelay * 1000, CancelToken); - } - catch (OperationCanceledException) - { - return; - } - } - else - LogError("[OBS] The specified delay is not in between 1 and 20 seconds. The delay will be skipped."); - } - - Program.obsWebSocket.Send($"{{\"request-type\":\"StopRecording\", \"message-id\":\"StopRecording\"}}"); - return; - } - } - else - { - LogError("[OBS] The WebSocket isn't connected, no recording can be stopped."); - } - } -} diff --git a/BeatRecorder/OBSWebSocket/OBSWebSocketEvents.cs b/BeatRecorder/OBSWebSocket/OBSWebSocketEvents.cs deleted file mode 100644 index d7dc5d7..0000000 --- a/BeatRecorder/OBSWebSocket/OBSWebSocketEvents.cs +++ /dev/null @@ -1,270 +0,0 @@ -namespace BeatRecorder; - -class OBSWebSocketEvents -{ - internal static string RequiredAuthenticationGuid = Guid.NewGuid().ToString(); - internal static string AuthenticationGuid = Guid.NewGuid().ToString(); - internal static string CheckIfRecording = Guid.NewGuid().ToString(); - - internal static async Task MessageReceived(ResponseMessage msg) - { - if (msg.Text.Contains($"\"message-id\":\"{RequiredAuthenticationGuid}\"")) - { - AuthenticationRequired required = JsonConvert.DeserializeObject(msg.Text); - - if (required.authRequired) - { - LogInfo("[OBS] Authenticating.."); - - if (Program.LoadedSettings.OBSPassword == "") - { - UIHandler.OBSPasswordRequired = true; - - await Task.Delay(1000); - LogInfo("[OBS] A password is required to log into your obs websocket."); - await Task.Delay(1000); - Console.Write("> "); - - // I was to lazy to write my own.. https://stackoverflow.com/questions/3404421/password-masking-console-application - - string Password = ""; - - ConsoleKey key = ConsoleKey.A; - - while (key != ConsoleKey.Enter || key != ConsoleKey.Escape) - { - var keyInfo = Console.ReadKey(intercept: true); - key = keyInfo.Key; - - if (key == ConsoleKey.Backspace && Password.Length > 0) - { - Console.Write("\b \b"); - Password = Password[0..^1]; - } - else if (!char.IsControl(keyInfo.KeyChar)) - { - Console.Write("*"); - Password += keyInfo.KeyChar; - } - else if (key == ConsoleKey.Escape) - { - LogInfo("[OBS] Cancelled. Press any key to exit."); - Console.ReadKey(); - Environment.Exit(0); - return; - } - else if (key == ConsoleKey.Enter) - { - Console.Write("\r \r"); - break; - } - } - - if (key == ConsoleKey.Enter) - { - if (Program.LoadedSettings.AskToSaveOBSPassword) - { - key = ConsoleKey.A; - - LogWarn("[OBS] Do you want to save this password in the config? (THIS WILL STORE THE PASSWORD IN PLAIN-TEXT, THIS CAN BE ACCESSED BY ANYONE WITH ACCESS TO YOUR FILES. THIS IS NOT RECOMMENDED!)"); - while (key != ConsoleKey.Enter || key != ConsoleKey.Escape || key != ConsoleKey.Y || key != ConsoleKey.N) - { - await Task.Delay(1000); - Console.Write("[OBS] y/N > "); - - var keyInfo = Console.ReadKey(intercept: true); - Console.Write("\r \r"); - key = keyInfo.Key; - - if (key == ConsoleKey.Escape) - { - LogWarn("[OBS] Cancelled. Press any key to exit."); - Console.ReadKey(); - Environment.Exit(0); - return; - } - else if (key == ConsoleKey.Y) - { - LogInfo("[OBS] Your password is now saved in the Settings.json."); - Program.LoadedSettings.OBSPassword = Password; - Program.LoadedSettings.AskToSaveOBSPassword = true; - - File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedSettings, Formatting.Indented)); - break; - } - else if (key == ConsoleKey.N || key == ConsoleKey.Enter) - { - LogInfo("[OBS] Your password will not be saved. This wont be asked in the feature."); - LogInfo("[OBS] To re-enable this prompt, set AskToSaveOBSPassword to true in the Settings.json."); - Program.LoadedSettings.OBSPassword = ""; - Program.LoadedSettings.AskToSaveOBSPassword = false; - - File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedSettings, Formatting.Indented)); - break; - } - } - } - - Program.LoadedSettings.OBSPassword = Password; - } - } - - string secret = Extensions.HashEncode(Program.LoadedSettings.OBSPassword + required.salt); - string authResponse = Extensions.HashEncode(secret + required.challenge); - - Program.obsWebSocket.Send($"{{\"request-type\":\"Authenticate\", \"message-id\":\"{AuthenticationGuid}\", \"auth\":\"{authResponse}\"}}"); - } - else - { - Program.obsWebSocket.Send($"{{\"request-type\":\"GetRecordingStatus\", \"message-id\":\"{CheckIfRecording}\"}}"); - } - } - else if (msg.Text.Contains($"\"message-id\":\"{AuthenticationGuid}\"")) - { - AuthenticationRequired required = JsonConvert.DeserializeObject(msg.Text); - - if (required.status == "ok") - { - Program.SendNotification("Authenticated with OBS.", 1000, MessageType.INFO); - LogInfo("[OBS] Authenticated."); - - Program.obsWebSocket.Send($"{{\"request-type\":\"GetRecordingStatus\", \"message-id\":\"{CheckIfRecording}\"}}"); - } - else - { - LogError("[OBS] Failed to authenticate. Please check your password or wait a few seconds to try authentication again."); - await Program.obsWebSocket.Stop(WebSocketCloseStatus.NormalClosure, "Shutting down"); - - await Task.Delay(1000); - - LogInfo("[OBS] Re-trying.."); - await Program.obsWebSocket.Start(); - Program.obsWebSocket.Send($"{{\"request-type\":\"GetAuthRequired\", \"message-id\":\"{RequiredAuthenticationGuid}\"}}"); - } - } - else if (msg.Text.Contains($"\"message-id\":\"{CheckIfRecording}\"")) - { - RecordingStatus recordingStatus = JsonConvert.DeserializeObject(msg.Text); - - OBSWebSocketStatus.OBSRecording = recordingStatus.isRecording; - OBSWebSocketStatus.OBSRecordingPaused = recordingStatus.isRecordingPaused; - - if (recordingStatus.isRecording) - LogWarn($"[OBS] A recording is already running."); - } - - if (msg.Text.Contains("\"update-type\":\"RecordingStopped\"")) - { - Program.SendNotification("Recording stopped.", 1000, MessageType.INFO); - RecordingStopped RecordingStopped = JsonConvert.DeserializeObject(msg.Text); - - LogInfo($"[OBS] Recording stopped."); - OBSWebSocketStatus.OBSRecording = false; - - if (Program.LoadedSettings.Mod == "http-status") - HttpStatus.HandleFile(HttpStatusStatus.HttpStatusLastBeatmap, HttpStatusStatus.HttpStatusLastPerformance, RecordingStopped.recordingFilename, HttpStatusStatus.FinishedLastSong, HttpStatusStatus.FailedLastSong); - else if (Program.LoadedSettings.Mod == "datapuller") - DataPuller.HandleFile(DataPullerStatus.DataPullerLastBeatmap, DataPullerStatus.DataPullerLastPerformance, RecordingStopped.recordingFilename, DataPullerStatus.LastSongCombo); - } - else if (msg.Text.Contains("\"update-type\":\"RecordingStarted\"")) - { - Program.SendNotification("Recording started.", 1000, MessageType.INFO); - LogInfo($"[OBS] Recording started."); - OBSWebSocketStatus.OBSRecording = true; - while (OBSWebSocketStatus.OBSRecording) - { - await Task.Delay(1000); - - if (!OBSWebSocketStatus.OBSRecordingPaused) - { - OBSWebSocketStatus.RecordingSeconds++; - } - } - OBSWebSocketStatus.RecordingSeconds = 0; - } - else if (msg.Text.Contains("\"update-type\":\"RecordingPaused\"")) - { - Program.SendNotification("Recording paused.", 1000, MessageType.INFO); - LogInfo($"[OBS] Recording paused."); - OBSWebSocketStatus.OBSRecordingPaused = true; - } - else if (msg.Text.Contains("\"update-type\":\"RecordingResumed\"")) - { - Program.SendNotification("Recording resumed.", 500, MessageType.INFO); - LogInfo($"[OBS] Recording resumed."); - OBSWebSocketStatus.OBSRecordingPaused = false; - } - } - - internal static void Reconnected(ReconnectionInfo msg) - { - if (msg.Type != ReconnectionType.Initial) - { - LogInfo($"[OBS] Reconnected: {msg.Type}"); - - Objects.LastOBSWarning = ConnectionTypeWarning.CONNECTED; - - Program.obsWebSocket.Send($"{{\"request-type\":\"GetAuthRequired\", \"message-id\":\"{RequiredAuthenticationGuid}\"}}"); - } - } - - internal static void Disconnected(DisconnectionInfo msg) - { - try - { - Process[] processCollection = Process.GetProcesses(); - - if (!processCollection.Any(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32"))) - { - if (Objects.LastOBSWarning != ConnectionTypeWarning.NO_PROCESS) - { - Program.SendNotification("Couldn't connect to OBS, is it even running?", 10000, MessageType.ERROR); - LogWarn($"[OBS] Couldn't find an OBS process, is your OBS running? ({msg.Type})"); - } - Objects.LastOBSWarning = ConnectionTypeWarning.NO_PROCESS; - } - else - { - bool FoundWebSocketDll = false; - - string OBSInstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32")).MainModule.FileName; - OBSInstallationDirectory = OBSInstallationDirectory.Remove(OBSInstallationDirectory.LastIndexOf("\\bin"), OBSInstallationDirectory.Length - OBSInstallationDirectory.LastIndexOf("\\bin")); - - if (Directory.GetDirectories(OBSInstallationDirectory).Any(x => x.ToLower().EndsWith("obs-plugins"))) - { - foreach (var b in Directory.GetDirectories($"{OBSInstallationDirectory}\\obs-plugins")) - { - if (Directory.GetFiles(b).Any(x => x.Contains("obs-websocket") && x.EndsWith(".dll"))) - { - FoundWebSocketDll = true; - break; - } - } - } - - if (FoundWebSocketDll) - { - if (Objects.LastOBSWarning != ConnectionTypeWarning.MOD_INSTALLED) - { - LogFatal($"[OBS] OBS seems to be running but the obs-websocket server isn't running. Please make sure you have the obs-websocket server activated! (Tools -> WebSocket Server Settings) ({msg.Type})"); - Program.SendNotification("Couldn't connect to OBS. Please make sure obs-websocket is enabled.", 10000, MessageType.ERROR); - } - Objects.LastOBSWarning = ConnectionTypeWarning.MOD_INSTALLED; - } - else - { - if (Objects.LastOBSWarning != ConnectionTypeWarning.MOD_NOT_INSTALLED) - { - LogFatal($"[OBS] OBS seems to be running but the obs-websocket server isn't installed. Please make sure you have the obs-websocket server installed! (To install, follow this link: https://bit.ly/3BCXfeS) ({msg.Type})"); - Program.SendNotification("Couldn't connect to OBS. Please make sure obs-websocket is installed.", 10000, MessageType.ERROR); - } - Objects.LastOBSWarning = ConnectionTypeWarning.MOD_NOT_INSTALLED; - } - } - } - catch (Exception ex) - { - LogError($"Failed to check if obs-websocket is installed: (Disconnect Reason: {msg.Type}) {ex}"); - } - } -} diff --git a/BeatRecorder/Objects.cs b/BeatRecorder/Objects.cs deleted file mode 100644 index b90f3db..0000000 --- a/BeatRecorder/Objects.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace BeatRecorder; - -class Objects -{ - public static bool SettingsRequired = false; - public static bool UpdateAvailable = false; - public static string UpdateText = ""; - - public static ulong SteamNotificationId = 0; - - public static ConnectionTypeWarning LastDP1Warning { get; set; } - public static ConnectionTypeWarning LastHttpStatusWarning { get; set; } - public static ConnectionTypeWarning LastOBSWarning { get; set; } -} diff --git a/BeatRecorder/Program.cs b/BeatRecorder/Program.cs index e1afe28..203c2d9 100644 --- a/BeatRecorder/Program.cs +++ b/BeatRecorder/Program.cs @@ -1,14 +1,27 @@ -namespace BeatRecorder; +using BeatRecorder.Entities; +using BeatRecorder.Util; +using BeatRecorder.Util.BeatSaber; +using BeatRecorder.Util.OBS; +using BeatRecorder.Util.OpenVR; -class Program +namespace BeatRecorder; + +public class Program { - public static string CurrentVersion = "1.6.1"; + public static string Version = "2.0-RC1"; + + public bool RunningPrerelease = false; + + internal Config LoadedConfig { get; set; } = null; + + public BaseObsHandler ObsClient { get; set; } - public static Settings LoadedSettings = new(); + public BaseBeatSaberHandler BeatSaberClient { get; set; } = null; + + public SteamNotifications steamNotifications { get; set; } = null; + + public UIHandler GUI { get; set; } = null; - internal static WebsocketClient beatSaberWebSocket { get; set; } - internal static WebsocketClient beatSaberWebSocketLiveData { get; set; } - internal static WebsocketClient obsWebSocket { get; set; } static void Main(string[] args) { @@ -19,369 +32,196 @@ static void Main(string[] args) private async Task MainAsync(string[] args) { - Console.Clear(); - - Console.SetWindowSize(160, 40); + Console.ResetColor(); if (!Directory.Exists("logs")) Directory.CreateDirectory("logs"); - - StartLogger($"logs/{DateTime.UtcNow:dd-MM-yyyy_HH-mm-ss}.log", LogLevel.DEBUG, DateTime.UtcNow.AddDays(-3), false); - - LogInfo("[BR] Loading settings.."); + _logger = StartLogger($"logs/{DateTime.UtcNow:dd-MM-yyyy_HH-mm-ss}.log", Xorog.Logger.Enums.LogLevel.INFO, DateTime.UtcNow.AddDays(-3), false); _ = Task.Run(async () => { - await Task.Delay(30000); + await Task.Delay(1000); if (Process.GetProcessesByName(Process.GetCurrentProcess().ProcessName).Length > 1) { - LogError("Only one instance of this application is allowed"); + _logger.LogError("Only one instance of this application is allowed"); Environment.Exit(0); return; } }); - - if (File.Exists("Settings.json")) + + if (!File.Exists("Settings.json")) + File.WriteAllText("Settings.json", JsonConvert.SerializeObject(new Config())); + + try { - try - { - LoadedSettings = JsonConvert.DeserializeObject(File.ReadAllText("Settings.json")); - File.WriteAllText("Settings.json", JsonConvert.SerializeObject(LoadedSettings, Formatting.Indented)); + LoadedConfig = JsonConvert.DeserializeObject(File.ReadAllText("Settings.json")); - ChangeLogLevel(LoadedSettings.ConsoleLogLevel); + if (LoadedConfig.Mod is not "http-status" and not "datapuller" and not "beatsaberplus") + throw new ArgumentException($"Invalid mod selected: {LoadedConfig.Mod}"); - if (LoadedSettings.Mod != "http-status" && LoadedSettings.Mod != "datapuller") - { - throw new Exception("Invalid Mod selected."); - } + _logger.ChangeLogLevel(LoadedConfig.ConsoleLogLevel); - if (!string.IsNullOrWhiteSpace(LoadedSettings.OBSPassword)) - AddBlacklist(LoadedSettings.OBSPassword); - } - catch (Exception ex) - { - LogError($"[BR] Exception occured while loading config: {ex}"); - ResetSettings(); - return; - } + #if DEBUG + _logger.ChangeLogLevel(LogLevel.TRACE); + #endif - LogInfo("[BR] Settings loaded."); + if (!string.IsNullOrWhiteSpace(LoadedConfig.OBSPassword)) + _logger.AddBlacklist(LoadedConfig.OBSPassword); + + _logger.AddBlacklist(Environment.UserName); + _logger.AddBlacklist(Environment.UserDomainName); + _logger.AddBlacklist(Environment.MachineName); + + _logger.LogInfo("Settings loaded"); + _logger.LogDebug($"{JsonConvert.SerializeObject(LoadedConfig)}"); } - else + catch (Exception ex) { - ResetSettings(); - return; + _logger.LogError("Failed to load config", ex); + await Task.Delay(500); + Environment.Exit(1); + throw; } - LogDebug($"Enviroment Details\n\n" + - $"Dotnet Version: {Environment.Version}\n" + - $"OS & Version: {Environment.OSVersion}\n\n" + - $"OS 64x: {Environment.Is64BitOperatingSystem}\n" + - $"Process 64x: {Environment.Is64BitProcess}\n\n" + - $"Current Directory: {Environment.CurrentDirectory}\n" + - $"Commandline: {Environment.CommandLine}\n"); + _logger.LogDebug($"Enviroment Details\n\n" + + $"Dotnet Version: {Environment.Version}\n" + + $"OS & Version: {Environment.OSVersion}\n\n" + + $"OS 64x: {Environment.Is64BitOperatingSystem}\n" + + $"Process 64x: {Environment.Is64BitProcess}\n\n" + + $"Current Directory: {Environment.CurrentDirectory}\n" + + $"Base Directory: {AppDomain.CurrentDomain.BaseDirectory}\n" + + $"Commandline: {Environment.CommandLine}\n"); - LogDebug($"Loaded settings:\n\n{JsonConvert.SerializeObject(LoadedSettings, Formatting.Indented)}\n"); - LogDebug($"{AppDomain.CurrentDomain.BaseDirectory}"); - LogDebug($"{Environment.CurrentDirectory}"); + if (LoadedConfig.DisplayUI) + GUI = UIHandler.Initialize(this); - OBSWebSocketStatus.CancelStopRecordingDelay = new CancellationTokenSource(); + _ = Task.Run(() => + { + if (LoadedConfig.DisplaySteamNotifications) + steamNotifications = SteamNotifications.Initialize(); + }); - await Task.Run(async () => + _ = Task.Run(async () => { try { var github = new GitHubClient(new ProductHeaderValue("BeatRecorderUpdateCheck")); - var repo = await github.Repository.Release.GetLatest("TheXorog", "BeatRecorder"); + IReadOnlyList releases = await github.Repository.Release.GetAll("TheXorog", "BeatRecorder"); - LogInfo($"[BR] Current latest release is \"{repo.TagName}\". You're currently running: \"{CurrentVersion}\""); + #if DEBUG + Version += "-dev"; + #endif - if (repo.TagName != CurrentVersion) - { - LogFatal($"[BR] You're running an outdated version of BeatRecorder, please update at https://github.com/TheXorog/BeatRecorder/releases/latest." + - $"\n\nWhat changed in the new version:\n\n" + - $"{repo.Body}\n"); + RunningPrerelease = (Version.ToLower().Contains("dev") || Version.ToLower().Contains("rc") || Version.ToLower().Contains("beta") || Version.ToLower().Contains("alpha")); - Objects.UpdateText = repo.Body; - Objects.UpdateAvailable = true; - } - } - catch (Exception ex) - { - LogError($"[BR] Unable to get latest version: {ex}"); - } - }); + if (RunningPrerelease) + _logger.LogWarn("You're running a pre-release version of BeatRecorder. If you find any bugs, please report them at https://github.com/TheXorog/BeatRecorder"); - switch (LoadedSettings.Mod) - { - case "datapuller": - { - // Connect to MapData Endpoint of DataPuller's WebSocket + Release repo = null; - _ = Task.Run(() => + foreach (var rel in releases) { - var factory = new Func(() => new ClientWebSocket + if (rel.Prerelease && RunningPrerelease) { - Options = - { - KeepAliveInterval = TimeSpan.FromSeconds(5) - } - }); + repo = rel; + break; + } - beatSaberWebSocket = new WebsocketClient(new Uri($"ws://{LoadedSettings.BeatSaberUrl}:{LoadedSettings.BeatSaberPort}/BSDataPuller/MapData"), factory) + if (!rel.Prerelease) { - ReconnectTimeout = null, - ErrorReconnectTimeout = TimeSpan.FromSeconds(3) - }; - - beatSaberWebSocket.MessageReceived.Subscribe(msg => { DataPuller.MapDataMessageRecieved(msg.Text); }); - beatSaberWebSocket.ReconnectionHappened.Subscribe(type => { DataPuller.MapDataReconnected(type); }); - beatSaberWebSocket.DisconnectionHappened.Subscribe(type => { DataPuller.MapDataDisconnected(type); }); - - LogInfo($"[BS-DP1] Connecting.."); - beatSaberWebSocket.Start().Wait(); - LogInfo("[BS-DP1] Connected."); - }); + repo = rel; + break; + } + } - // Connect to LiveData Endpoint of DataPuller's WebSocket or HttpStatus' Endpoint + if (repo is null) + throw new Exception("Failed to get latest version."); - _ = Task.Run(() => - { - // https://github.com/kOFReadie/BSDataPuller - - var factory = new Func(() => new ClientWebSocket - { - Options = - { - KeepAliveInterval = TimeSpan.FromSeconds(5) - } - }); - - beatSaberWebSocketLiveData = new WebsocketClient(new Uri($"ws://{LoadedSettings.BeatSaberUrl}:{LoadedSettings.BeatSaberPort}/BSDataPuller/LiveData"), factory) - { - ReconnectTimeout = null, - ErrorReconnectTimeout = TimeSpan.FromSeconds(3) - }; - - beatSaberWebSocketLiveData.MessageReceived.Subscribe(msg => { DataPuller.LiveDataMessageRecieved(msg.Text); }); - beatSaberWebSocketLiveData.ReconnectionHappened.Subscribe(type => { DataPuller.LiveDataReconnected(type); }); - beatSaberWebSocketLiveData.DisconnectionHappened.Subscribe(type => { DataPuller.LiveDataDisconnected(type); }); - - LogDebug($"[BS-DP2] Connecting.."); - beatSaberWebSocketLiveData.Start().Wait(); - LogDebug("[BS-DP2] Connected."); - }); - break; - } + _logger.LogInfo($"Current latest release is \"{repo.TagName}\". You're currently running: \"{Version}\""); - case "http-status": - { - _ = Task.Run(() => + if (repo.TagName != Version && !Version.Contains("dev")) { - // https://github.com/opl-/beatsaber-http-status/blob/master/protocol.md - // https://github.com/kOFReadie/BSDataPuller - - var factory = new Func(() => new ClientWebSocket - { - Options = - { - KeepAliveInterval = TimeSpan.FromSeconds(5) - } - }); + _logger.LogFatal($"You're running an outdated version of BeatRecorder, please update at https://github.com/TheXorog/BeatRecorder/releases/latest." + + $"\n\nWhat changed in the new version:\n\n" + + $"{repo.Body}\n"); - beatSaberWebSocket = new WebsocketClient(new Uri($"ws://{LoadedSettings.BeatSaberUrl}:{LoadedSettings.BeatSaberPort}/socket"), factory) - { - ReconnectTimeout = null, - ErrorReconnectTimeout = TimeSpan.FromSeconds(3) - }; - beatSaberWebSocket.MessageReceived.Subscribe(msg => { HttpStatus.MessageReceived(msg.Text); }); - beatSaberWebSocket.ReconnectionHappened.Subscribe(type => { HttpStatus.Reconnected(type); }); - beatSaberWebSocket.DisconnectionHappened.Subscribe(type => { HttpStatus.Disconnected(type); }); - - LogInfo($"[BS-HS] Connecting.."); - beatSaberWebSocket.Start().Wait(); - }); - break; + _ = Task.Run(() => { GUI.ShowNotification($"You're running an outdated version of BeatRecorder.\nVersion {repo.TagName} is available.\n\n{repo.Body}", "New version available", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Warning); }); + } } - } - - _ = Task.Run(() => - { - // https://github.com/Palakis/obs-websocket/blob/4.x-current/docs/generated/protocol.md - - var factory = new Func(() => new ClientWebSocket - { - Options = - { - KeepAliveInterval = TimeSpan.FromSeconds(5) - } - }); - - obsWebSocket = new WebsocketClient(new Uri($"ws://{LoadedSettings.OBSUrl}:{LoadedSettings.OBSPort}"), factory) + catch (Exception ex) { - ReconnectTimeout = null, - ErrorReconnectTimeout = TimeSpan.FromSeconds(3) - }; - - obsWebSocket.MessageReceived.Subscribe(msg => { _ = OBSWebSocketEvents.MessageReceived(msg); }); - obsWebSocket.ReconnectionHappened.Subscribe(type => { OBSWebSocketEvents.Reconnected(type); }); - obsWebSocket.DisconnectionHappened.Subscribe(type => { OBSWebSocketEvents.Disconnected(type); }); - - LogInfo($"[OBS] Connecting.."); - obsWebSocket.Start().Wait(); - - obsWebSocket.Send($"{{\"request-type\":\"GetAuthRequired\", \"message-id\":\"{OBSWebSocketEvents.RequiredAuthenticationGuid}\"}}"); - - LogInfo($"[OBS] Connected."); - - SendNotification("Connected to OBS", 1000, MessageType.INFO); + _logger.LogError($"Unable to get latest version", ex); + } }); - if (LoadedSettings.DisplayUI) + _ = Task.Run(async () => { - UIHandler handler = new(); - _ = handler.HandleUI(); - } - - NotifcationLoop(); - - // Don't close the application - await Task.Delay(-1); - } - - private static void ResetSettings() - { - LoadedSettings = new(); + HttpClient httpClient = new(); + httpClient.Timeout = TimeSpan.FromSeconds(3); - if (File.Exists("Settings.json")) - { - try + async Task UseModernSocket() { - if (File.Exists("Settings.json.old")) - File.Delete("Settings.json.old"); + try + { + _logger.LogDebug($"Checking if obs-websocket v5 is available at {this.LoadedConfig.OBSUrl}:{this.LoadedConfig.OBSPortModern}.."); + var response = await httpClient.GetAsync($"http://{this.LoadedConfig.OBSUrl}:{this.LoadedConfig.OBSPortModern}"); + _logger.LogDebug($"obs-websocket v5 is available"); + return true; + } + catch (Exception) + { + _logger.LogWarn($"obs-websocket is not available, attempting fall back to legacy.."); - File.Copy("Settings.json", "Settings.json.old"); + try + { + var response = await httpClient.GetAsync($"http://{this.LoadedConfig.OBSUrl}:{this.LoadedConfig.OBSPortLegacy}"); + _logger.LogWarn($"obs-websocket v4 is available. While still supported, you should update to obs websocket v5 here: https://github.com/obsproject/obs-websocket/releases"); + return false; + } + catch (Exception) + { + _logger.LogWarn($"obs-websocket v4 is not available, re-checking.."); + return await UseModernSocket(); + } + } } - catch { } - } - File.WriteAllText("Settings.json", JsonConvert.SerializeObject(LoadedSettings, Formatting.Indented)); - - SendNotification("Your settings were reset due to an error. Please check your desktop.", 10000, MessageType.ERROR); - LogInfo($"Please configure BeatRecorder using the config file that was just opened. If you're done, save and quit notepad and BeatRecorder will restart for you."); - - Objects.SettingsRequired = true; - var infoUI = new InfoUI(CurrentVersion, LoadedSettings.DisplayUITopmost, Objects.SettingsRequired); - infoUI.ShowDialog(); - LogDebug("Settings updated via UI"); - Process.Start(Environment.ProcessPath); - Thread.Sleep(2000); - Environment.Exit(0); - return; - } - - public static List NotificationList = new(); + if (await UseModernSocket()) + this.ObsClient = ObsHandler.Initialize(this); + else + this.ObsClient = LegacyObsHandler.Initialize(this); + }); - public static void NotifcationLoop() - { - if (LoadedSettings.DisplaySteamNotifications) + _ = Task.Run(async () => { - _ = Task.Run(() => - { - LogInfo("Loading Notification Assets.."); - Bitmap InfoIcon; - Bitmap ErrorIcon; + while (ObsClient is null) + await Task.Delay(100); - try + switch (LoadedConfig.Mod) + { + case "http-status": { - InfoIcon = new($"{AppDomain.CurrentDomain.BaseDirectory}Assets\\Info.png"); - ErrorIcon = new($"{AppDomain.CurrentDomain.BaseDirectory}Assets\\Error.png"); + BeatSaberClient = new HttpStatusHandler().Initialize(this); // 6557 + break; } - catch (Exception ex) + case "datapuller": { - LogFatal("Failed load Notifaction Assets", ex); - return; + BeatSaberClient = new DataPullerHandler().Initialize(this); // 2946 + break; } - - while (true) + case "beatsaberplus": { - try - { - if (Objects.SteamNotificationId == 0) - { - LogDebug($"[BR] Initializing OpenVR.."); - bool Initialized = false; - - while (!Initialized) - { - Initialized = EasyOpenVRSingleton.Instance.Init(); - Thread.Sleep(500); - } - - LogDebug($"[BR] Initialized OpenVR."); - - LogDebug($"[BR] Initializing NotificationOverlay.."); - Objects.SteamNotificationId = EasyOpenVRSingleton.Instance.InitNotificationOverlay("BeatRecorder"); - LogDebug($"[BR] Initialized NotificationOverlay: {Objects.SteamNotificationId}"); - } - - while (NotificationList.Count == 0) - Thread.Sleep(500); - - NotificationBitmap_t NotifactionIcon; - - foreach (var b in NotificationList.ToList()) - { - BitmapData TextureData = new(); - - if (b.Type == MessageType.INFO) - TextureData = InfoIcon.LockBits(new Rectangle(0, 0, InfoIcon.Width, InfoIcon.Height), ImageLockMode.ReadOnly,PixelFormat.Format32bppArgb); - else if (b.Type == MessageType.ERROR) - TextureData = ErrorIcon.LockBits(new Rectangle(0, 0, ErrorIcon.Width, ErrorIcon.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); - - NotifactionIcon.m_pImageData = TextureData.Scan0; - NotifactionIcon.m_nWidth = TextureData.Width; - NotifactionIcon.m_nHeight = TextureData.Height; - NotifactionIcon.m_nBytesPerPixel = 4; - - var NotificationId = EasyOpenVRSingleton.Instance.EnqueueNotification(Objects.SteamNotificationId, EVRNotificationType.Persistent, b.Message, EVRNotificationStyle.Application, NotifactionIcon); - LogDebug($"[BR] Displayed Notification {NotificationId}: {b.Message}"); - - if (b.Type == MessageType.INFO) - InfoIcon.UnlockBits(TextureData); - else if (b.Type == MessageType.ERROR) - ErrorIcon.UnlockBits(TextureData); - - if (NotificationId == 0) - return; - - Thread.Sleep(b.Delay); - EasyOpenVRSingleton.Instance.DismissNotification(NotificationId, out var error); - - if (error != EVRNotificationError.OK) - { - LogFatal($"Failed to dismiss notification {Objects.SteamNotificationId}: {error}"); - } - - LogDebug($"[BR] Dismissed Notification {NotificationId}"); - - NotificationList.Remove(b); - } - } - catch (Exception ex) - { - LogError(ex.ToString()); - Thread.Sleep(5000); - continue; - } + _ = Task.Run(() => { GUI.ShowNotification("BeatSaberPlus Integration is currently incomplete. Filenames will always appear as if you finished the song.", "Warning", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Warning); }); + _logger.LogFatal("BeatSaberPlus Integration is currently incomplete. BSP does not provide a way of knowing if a song was failed or finished, making filenames always seem like the song was finished."); + _logger.LogFatal("To continue anyways, wait 10 seconds."); + await Task.Delay(10000); + BeatSaberClient = new BeatSaberPlusHandler().Initialize(this); // 2947 + break; } - }); - } - } + } + }); - public static void SendNotification(string Text, int DisplayTime = 2000, MessageType messageType = MessageType.INFO) - { - NotificationList.Add(new NotificationEntry { Message = Text, Delay = DisplayTime, Type = messageType }); + await Task.Delay(-1); } } diff --git a/BeatRecorder/UIHandler.cs b/BeatRecorder/UIHandler.cs deleted file mode 100644 index 20cae76..0000000 --- a/BeatRecorder/UIHandler.cs +++ /dev/null @@ -1,311 +0,0 @@ -namespace BeatRecorder; - -internal class UIHandler -{ - public static bool OBSPasswordRequired = false; - - internal async Task HandleUI() - { - await Task.Delay(500); - - bool DisplayedUpdateNotice = false; - - if (!Program.LoadedSettings.HideConsole) - ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 2); - else - ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 0); - - LogDebug($"Displaying InfoUI"); - - var infoUI = new InfoUI(Program.CurrentVersion, Program.LoadedSettings.DisplayUITopmost, Objects.SettingsRequired); - - _ = Task.Run(async () => - { - string lastCover = ""; - Image coverArt = null; - - Action UpdateUI = new(() => - { - try - { - if (OBSPasswordRequired) - { - Thread.Sleep(3000); - LogDebug($"Trying to display password prompt"); - infoUI.Hide(); - - MessageBox.Show($"A password is required to log into the obs websocket.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - - var infoUI2 = new InfoUI(Program.CurrentVersion, Program.LoadedSettings.DisplayUITopmost, true); - infoUI2.ShowDialog(); - Process.Start(Environment.ProcessPath); - Thread.Sleep(2000); - Environment.Exit(0); - return; - } - - if (Objects.UpdateAvailable && !DisplayedUpdateNotice) - { - DisplayedUpdateNotice = true; - MessageBox.Show($"There's a new version available.\n\n{Objects.UpdateText}", "Info", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - - if (Program.obsWebSocket.IsRunning) - { - if (infoUI.OBSConnectionLabel.BackColor != Color.DarkGreen && infoUI.OBSConnectionLabel.BackColor != Color.Orange) - infoUI.OBSConnectionLabel.BackColor = Color.DarkGreen; - } - else - { - if (infoUI.OBSConnectionLabel.BackColor != Color.DarkRed) - infoUI.OBSConnectionLabel.BackColor = Color.DarkRed; - } - - if (Program.beatSaberWebSocket.IsRunning) - { - if (infoUI.BeatSaberConnectionLabel.BackColor != Color.DarkGreen) - infoUI.BeatSaberConnectionLabel.BackColor = Color.DarkGreen; - } - else - { - if (infoUI.BeatSaberConnectionLabel.BackColor != Color.DarkRed) - infoUI.BeatSaberConnectionLabel.BackColor = Color.DarkRed; - } - - if (OBSWebSocketStatus.OBSRecording) - { - infoUI.OBSConnectionLabel.Text = "(REC) OBS"; - - if (infoUI.OBSConnectionLabel.BackColor != Color.Orange) - infoUI.OBSConnectionLabel.BackColor = Color.Orange; - } - else - { - infoUI.OBSConnectionLabel.Text = "OBS"; - - if (!Program.obsWebSocket.IsRunning) - { - if (infoUI.OBSConnectionLabel.BackColor != Color.DarkRed) - infoUI.OBSConnectionLabel.BackColor = Color.DarkRed; - } - else - { - if (infoUI.OBSConnectionLabel.BackColor != Color.DarkGreen) - infoUI.OBSConnectionLabel.BackColor = Color.DarkGreen; - } - } - - if (!Program.beatSaberWebSocket.IsRunning) - return; - - if (HttpStatusStatus.HttpStatusCurrentPerformance is null && DataPullerStatus.DataPullerCurrentBeatmap is null) - return; - - switch (Program.LoadedSettings.Mod) - { - case "http-status": - { - if (HttpStatusStatus.HttpStatusCurrentBeatmap != null) - { - infoUI.SongNameLabel.Text = $"{HttpStatusStatus.HttpStatusCurrentBeatmap.songName}{(HttpStatusStatus.HttpStatusCurrentBeatmap.songSubName != "" ? $" {HttpStatusStatus.HttpStatusCurrentBeatmap.songSubName}" : "")}"; - infoUI.SongAuthorLabel.Text = HttpStatusStatus.HttpStatusCurrentBeatmap.songAuthorName; - infoUI.SongAuthorLabel.Location = new Point(infoUI.SongNameLabel.Location.X, infoUI.SongNameLabel.Location.Y + infoUI.SongNameLabel.Height); - infoUI.BSRLabel.Text = $""; - infoUI.MapperLabel.Text = $""; - - infoUI.ProgressLabel.Text = $"{TimeSpan.FromSeconds(OBSWebSocketStatus.RecordingSeconds).GetShortTimeFormat(Extensions.TimeFormat.MINUTES)}/{TimeSpan.FromSeconds(HttpStatusStatus.HttpStatusCurrentBeatmap.length / 1000).GetShortTimeFormat(Extensions.TimeFormat.MINUTES)}"; - - if (HttpStatusStatus.HttpStatusCurrentBeatmap?.songCover != null) - { - if (lastCover != HttpStatusStatus.HttpStatusCurrentBeatmap?.songCover) - { - LogDebug($"Generating cover art from base64 string.."); - - lastCover = HttpStatusStatus.HttpStatusCurrentBeatmap?.songCover; - - byte[] byteBuffer = Convert.FromBase64String(HttpStatusStatus.HttpStatusCurrentBeatmap?.songCover); - MemoryStream memoryStream = new(byteBuffer) - { - Position = 0 - }; - - Bitmap bmpReturn = (Bitmap)Bitmap.FromStream(memoryStream); - coverArt = bmpReturn; - } - } - else - { - if (lastCover != "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg") - { - Stopwatch sc = new(); - sc.Start(); - - LogWarn($"Failed to get cover art from song."); - LogDebug($"Downloading default cover art from 'https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg'.."); - - lastCover = "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg"; - - new HttpClient().GetStreamAsync("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg").ContinueWith(t => - { - sc.Stop(); - - if (!t.IsCompletedSuccessfully) - { - LogError($"Failed to download default cover art", t.Exception); - return; - } - - coverArt = Bitmap.FromStream(t.Result); - - LogDebug($"Downloaded default cover art from 'https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg' in {sc.ElapsedMilliseconds}ms"); - }); - } - } - } - - if (HttpStatusStatus.HttpStatusCurrentPerformance != null) - { - infoUI.ScoreLabel.Text = String.Format("{0:n0}", HttpStatusStatus.HttpStatusCurrentPerformance.score); - infoUI.ComboLabel.Text = $"{HttpStatusStatus.HttpStatusCurrentPerformance?.combo}x"; - infoUI.AccuracyLabel.Text = $"{Math.Round((decimal)((decimal)((decimal)HttpStatusStatus.HttpStatusCurrentPerformance.score / (decimal)HttpStatusStatus.HttpStatusCurrentPerformance.currentMaxScore) * 100), 2)}%"; - infoUI.MissesLabel.Text = $"{HttpStatusStatus.HttpStatusCurrentPerformance.missedNotes} Misses"; - - if (coverArt != null && infoUI.pictureBox1.Image != coverArt) - { - infoUI.pictureBox1.Image = coverArt; - } - } - - break; - } - case "datapuller": - { - if (DataPullerStatus.DataPullerCurrentBeatmap != null) - { - infoUI.SongNameLabel.Text = $"{DataPullerStatus.DataPullerCurrentBeatmap.SongName}{(DataPullerStatus.DataPullerCurrentBeatmap.SongSubName != "" ? $" {DataPullerStatus.DataPullerCurrentBeatmap.SongSubName}" : "")}"; - infoUI.SongAuthorLabel.Text = DataPullerStatus.DataPullerCurrentBeatmap.SongAuthor; - infoUI.SongAuthorLabel.Location = new Point(infoUI.SongNameLabel.Location.X, infoUI.SongNameLabel.Location.Y + infoUI.SongNameLabel.Height); - infoUI.BSRLabel.Text = $"BSR: {DataPullerStatus.DataPullerCurrentBeatmap.BSRKey?.ToString().TrimEnd()}"; - infoUI.MapperLabel.Text = $"Mapper: {DataPullerStatus.DataPullerCurrentBeatmap.Mapper}"; - - if (DataPullerStatus.DataPullerCurrentBeatmap?.coverImage != null) - { - if (lastCover != DataPullerStatus.DataPullerCurrentBeatmap?.coverImage?.ToString()) - { - Stopwatch sc = new(); - sc.Start(); - - LogDebug($"Downloading cover art from '{DataPullerStatus.DataPullerCurrentBeatmap.coverImage}'.."); - - lastCover = DataPullerStatus.DataPullerCurrentBeatmap.coverImage.ToString(); - - new HttpClient().GetStreamAsync(DataPullerStatus.DataPullerCurrentBeatmap.coverImage.ToString()).ContinueWith(t => - { - coverArt = Bitmap.FromStream(t.Result); - LogDebug($"Downloaded cover art from '{DataPullerStatus.DataPullerCurrentBeatmap.coverImage}' in {sc.ElapsedMilliseconds}ms"); - - sc.Stop(); - }); - } - } - else - { - // TODO: BeatSaver Details sometimes dont load which causes the cover fall back to default - - if (lastCover != "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg") - { - Stopwatch sc = new(); - sc.Start(); - - LogWarn($"Failed to get cover art from song."); - LogDebug($"Downloading default cover art from 'https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg'.."); - - lastCover = "https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg"; - - new HttpClient().GetStreamAsync("https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg").ContinueWith(t => - { - sc.Stop(); - - if (!t.IsCompletedSuccessfully) - { - LogError($"Failed to download default cover art", t.Exception); - return; - } - - coverArt = Bitmap.FromStream(t.Result); - - LogDebug($"Downloaded default cover art from 'https://raw.githubusercontent.com/TheXorog/BeatRecorder/main/BeatRecorder/Assets/BeatSaberIcon.jpg' in {sc.ElapsedMilliseconds}ms"); - }); - } - } - } - - if (DataPullerStatus.DataPullerCurrentPerformance != null) - { - infoUI.ScoreLabel.Text = String.Format("{0:n0}", DataPullerStatus.DataPullerCurrentPerformance.Score); - infoUI.ComboLabel.Text = $"{DataPullerStatus.DataPullerCurrentPerformance?.Combo}x"; - infoUI.AccuracyLabel.Text = $"{Math.Round((decimal)DataPullerStatus.DataPullerCurrentPerformance.Accuracy, 2)}%"; - infoUI.MissesLabel.Text = $"{DataPullerStatus.DataPullerCurrentPerformance.Misses} Misses"; - - infoUI.ProgressLabel.Text = $"{TimeSpan.FromSeconds(DataPullerStatus.DataPullerCurrentPerformance.TimeElapsed).GetShortTimeFormat(Extensions.TimeFormat.MINUTES)}/{TimeSpan.FromSeconds(DataPullerStatus.DataPullerCurrentBeatmap.Length).GetShortTimeFormat(Extensions.TimeFormat.MINUTES)}"; - - if (coverArt != null && infoUI.pictureBox1.Image != coverArt) - { - infoUI.pictureBox1.Image = coverArt; - } - } - - break; - } - default: - throw new Exception("Invalid Mod"); - } - } - catch { } - }); - - await Task.Delay(500); - - while (true) - { - if (infoUI.InvokeRequired) - infoUI.Invoke(UpdateUI); - else - UpdateUI(); - - await Task.Delay(250); - - while (OBSPasswordRequired) - await Task.Delay(1000); - } - }); - - infoUI.ShowDialog(); - ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 5); - - while (OBSPasswordRequired) - await Task.Delay(1000); - - if (infoUI.ShowConsoleAgain) - { - infoUI.ShowConsoleAgain = false; - infoUI.ShowConsole.Visible = false; - - ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 5); - infoUI.ShowDialog(); - } - - ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 5); - - if (infoUI.SettingsUpdated) - { - LogDebug("Settings updated via UI"); - Process.Start(Environment.ProcessPath); - await Task.Delay(5000); - Environment.Exit(0); - return; - } - LogDebug("InfoUI closed"); - Environment.Exit(0); - } -} diff --git a/BeatRecorder/Util/BeatSaber/BaseBeatSaberHandler.cs b/BeatRecorder/Util/BeatSaber/BaseBeatSaberHandler.cs new file mode 100644 index 0000000..7549f8f --- /dev/null +++ b/BeatRecorder/Util/BeatSaber/BaseBeatSaberHandler.cs @@ -0,0 +1,154 @@ +using BeatRecorder.Entities; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.BeatSaber; + +public abstract class BaseBeatSaberHandler +{ + public abstract BaseBeatSaberHandler Initialize(Program program); + public abstract SharedStatus GetCurrentStatus(); + public abstract SharedStatus GetLastCompletedStatus(); + internal abstract bool GetIsRunning(); + + public void HandleFile(string fileName, long RecordingLength, SharedStatus sharedStatus, Program program) + { + if (sharedStatus is null) + { + _logger.LogError($"Last completed status is null."); + return; + } + + _logger.LogTrace(fileName); + _logger.LogTrace(RecordingLength.ToString()); + _logger.LogTrace(JsonConvert.SerializeObject(sharedStatus)); + + bool DeleteFile = false; + string NewName = program.LoadedConfig.FileFormat; + + string GeneratedAccuracy = ""; + + if (sharedStatus.PerformanceInfo.SoftFailed.Value) + { + if (program.LoadedConfig.DeleteSoftFailed) + { + _logger.LogDebug("Song Soft-Failed. Deletion requested"); + DeleteFile = true; + } + + GeneratedAccuracy = $"NF-"; + } + + if (sharedStatus.PerformanceInfo.Finished.Value) + GeneratedAccuracy += sharedStatus.PerformanceInfo.Accuracy.ToString(); + else + { + if (program.LoadedConfig.DeleteIfQuitAfterSoftFailed) + { + _logger.LogDebug("Song Quit. Deletion requested"); + DeleteFile = true; + + if (GeneratedAccuracy == "NF-") + if (!program.LoadedConfig.DeleteIfQuitAfterSoftFailed) + { + _logger.LogDebug($"Song Soft-Failed but quit, deletion request reverted."); + DeleteFile = false; + } + } + + GeneratedAccuracy += $"QUIT"; + } + + if (sharedStatus.PerformanceInfo.Failed.Value) + { + if (program.LoadedConfig.DeleteFailed) + { + _logger.LogDebug("Song failed. Deletion requested"); + DeleteFile = true; + } + else + DeleteFile = false; + + GeneratedAccuracy = $"FAILED"; + } + + if (program.LoadedConfig.DeleteIfShorterThan > RecordingLength) + { + _logger.LogDebug("Recording too short. Deletion requested"); + DeleteFile = true; + } + + var ShortDifficulty = sharedStatus.BeatmapInfo.Difficulty.ToLower() switch + { + "expert" => "EX", + "expert+" or "expertplus" => "EX+", + _ => sharedStatus.BeatmapInfo.Difficulty.Truncate(1), + }; + + var missesText = sharedStatus.PerformanceInfo?.CombinedMisses?.ToString() ?? "0"; + + NewName = NewName.Replace("", sharedStatus.PerformanceInfo.Rank ?? "Z"); + NewName = NewName.Replace("", (GeneratedAccuracy.IsNullOrWhiteSpace() ? "Z" : GeneratedAccuracy)); + NewName = NewName.Replace("", sharedStatus.PerformanceInfo?.MaxCombo?.ToString() ?? "0"); + NewName = NewName.Replace("", sharedStatus.PerformanceInfo?.Score?.ToString() ?? "0"); + NewName = NewName.Replace("", sharedStatus.PerformanceInfo?.RawScore?.ToString() ?? "0"); + NewName = NewName.Replace("", (missesText == "0" ? "FC" : missesText)); + + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.Name ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.SubName ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.NameWithSub ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.Author ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.Creator ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.IdOrHash ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.Bpm?.ToString() ?? "Unknown"); + NewName = NewName.Replace("", sharedStatus.BeatmapInfo?.Difficulty ?? "Unknown"); + NewName = NewName.Replace("", ShortDifficulty ?? "Unknown"); + + if (!File.Exists(fileName)) + { + _logger.LogError($"{fileName} does not exist."); + return; + } + + foreach (char b in Path.GetInvalidFileNameChars()) + { + NewName = NewName.Replace(b, '_'); + } + + string FileExist = ""; + int FileExistCount = 2; + + FileInfo fileInfo = new(fileName); + + while (File.Exists($"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{fileInfo.Extension}")) + { + FileExist = $" ({FileExistCount})"; + FileExistCount++; + } + + string NewFileName = $"{fileInfo.Directory.FullName}\\{NewName}{FileExist}{fileInfo.Extension}"; + + try + { + if (DeleteFile) + { + File.Delete(fileName); + _logger.LogInfo("Recording deleted"); + + program.steamNotifications?.SendNotification("Recording deleted", 1000, MessageType.INFO); + } + else + { + File.Move(fileName, NewFileName); + _logger.LogInfo("Recording renamed"); + + program.steamNotifications?.SendNotification("Recording renamed", 1000, MessageType.INFO); + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to rename or delete '{fileName}'", ex); + } + } + + public Dictionary ImageCache = new(); +} diff --git a/BeatRecorder/Util/BeatSaber/BeatSaberPlusHandler.cs b/BeatRecorder/Util/BeatSaber/BeatSaberPlusHandler.cs new file mode 100644 index 0000000..57fd6a7 --- /dev/null +++ b/BeatRecorder/Util/BeatSaber/BeatSaberPlusHandler.cs @@ -0,0 +1,249 @@ +using BeatRecorder.Entities; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.BeatSaber; + +internal class BeatSaberPlusHandler : BaseBeatSaberHandler +{ + private WebsocketClient socket { get; set; } + + ConnectionTypeWarning LastWarning = ConnectionTypeWarning.Connected; + + private Program Program = null; + internal SharedStatus CurrentStatus => new(Current, GameInfo, CurrentMaxCombo, this); + internal SharedStatus LastCompletedStatus { get; set; } + + private SharedStatus.Game GameInfo { get; set; } = null; + + private BeatSaberPlus Current = new(); + + int CurrentMaxCombo = 0; + bool IsPlaying = false; + + public override BaseBeatSaberHandler Initialize(Program program) + { + _logger.LogInfo("Initializing Connection to Beat Saber via BeatSaberPlus.."); + + this.Program = program; + + var factory = new Func(() => new ClientWebSocket + { + Options = + { + KeepAliveInterval = TimeSpan.FromSeconds(5) + } + }); + + socket = new WebsocketClient(new Uri($"ws://{program.LoadedConfig.BeatSaberUrl}:{program.LoadedConfig.BeatSaberPort}/socket"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3) + }; + + socket.MessageReceived.Subscribe(msg => { MessageReceived(msg.Text); }); + socket.ReconnectionHappened.Subscribe(type => { Reconnected(type); }); + socket.DisconnectionHappened.Subscribe(type => { Disconnected(type); }); + + socket.Start().Wait(); + return this; + } + + public override SharedStatus GetCurrentStatus() => CurrentStatus; + + public override SharedStatus GetLastCompletedStatus() => LastCompletedStatus; + + private void MessageReceived(string text) + { + BeatSaberPlus _status; + + try + { + _status = JsonConvert.DeserializeObject(text); + } + catch (Exception ex) + { + _logger.LogFatal($"Unable to convert message into object", ex); + return; + } + + if (_status.mapInfoChanged is not null) + Current.mapInfoChanged = _status.mapInfoChanged; + + switch (_status._type) + { + case "handshake": + { + _logger.LogInfo($"Connected to Beat Saber via BeatSaberPlus"); + + GameInfo = new SharedStatus.Game + { + ModUsed = Mod.BeatSaberPlus, + ModVersion = _status.protocolVersion.ToString(), + GameVersion = _status.gameVersion + }; + break; + } + case "event": + { + switch (_status._event) + { + case "gameState": + { + if (_status.gameStateChanged.ToLower() == "menu" && IsPlaying) + { + IsPlaying = false; + + CurrentStatus.Update(new SharedStatus(_status, GameInfo, CurrentMaxCombo, this)); + LastCompletedStatus = CurrentStatus.Clone(); + _logger.LogInfo($"Stopped playing \"{LastCompletedStatus.BeatmapInfo.NameWithSub}\" by \"{LastCompletedStatus.BeatmapInfo.Author}\""); + _ = Program.ObsClient.StopRecording(); + + if (!Program.LoadedConfig.OBSMenuScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSMenuScene); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + + break; + } + else if (_status.gameStateChanged.ToLower() == "playing") + { + IsPlaying = true; + _logger.LogInfo($"Started playing \"{Current.mapInfoChanged.name}\" by \"{Current.mapInfoChanged.artist}\""); + + CurrentMaxCombo = 0; + Current.scoreEvent = new(); + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + + _ = Program.ObsClient.StartRecording(); + break; + } + break; + } + case "mapInfo": + { + Current.mapInfoChanged = _status.mapInfoChanged; + break; + } + case "score": + { + if ((Current.scoreEvent?.time ?? 0f) < _status.scoreEvent?.time && _status.scoreEvent is not null) + { + Current.scoreEvent = _status.scoreEvent; + } + + if (CurrentMaxCombo < _status.scoreEvent.combo) + CurrentMaxCombo = _status.scoreEvent.combo; + + break; + } + case "resume": + { + _logger.LogInfo($"Song resumed."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + + break; + } + case "pause": + { + _logger.LogInfo($"Song paused."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.PauseRecording(); + + if (!Program.LoadedConfig.OBSPauseScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSPauseScene); + + break; + } + } + break; + } + } + } + + private void Disconnected(DisconnectionInfo msg) + { + try + { + Process[] processCollection = Process.GetProcesses(); + + if (!processCollection.Any(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber"))) + { + if (LastWarning != ConnectionTypeWarning.NoProcess) + { + _logger.LogWarn($"Couldn't find a BeatSaber process, is BeatSaber started? ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NoProcess; + } + else + { + bool FoundWebSocketDll = false; + + string InstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber")).MainModule.FileName; + InstallationDirectory = InstallationDirectory.Remove(InstallationDirectory.LastIndexOf("\\"), InstallationDirectory.Length - InstallationDirectory.LastIndexOf("\\")); + + if (Directory.GetDirectories(InstallationDirectory).Any(x => x.ToLower().EndsWith("plugins"))) + { + if (Directory.GetFiles($"{InstallationDirectory}\\Plugins").Any(x => x.Contains("BeatSaberPlus") && x.EndsWith(".dll"))) + { + FoundWebSocketDll = true; + } + } + else + { + if (LastWarning != ConnectionTypeWarning.NotModded) + { + _logger.LogFatal($"Beat Saber seems to be running but the BeatSaberPlus modifaction doesn't seem to be installed. Is your game even modded? (If haven't modded it, please do it: https://bit.ly/2TAvenk. If already modded, install BeatSaberPlus: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NotModded; + return; + } + + if (FoundWebSocketDll) + { + if (LastWarning != ConnectionTypeWarning.ModInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running and the BeatSaberPlus modifaction seems to be installed. Please make sure you put in the right port and you installed all of BeatSaberPlus' dependiencies! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModInstalled; + } + else + { + if (LastWarning != ConnectionTypeWarning.ModNotInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running but the BeatSaberPlus modifaction doesn't seem to be installed. Please make sure to install BeatSaberPlus! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModNotInstalled; + } + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to check if BeatSaberPlus is installed: (Disconnect Reason: {msg.Type}) {ex}"); + } + } + + private void Reconnected(ReconnectionInfo msg) + { + Program.steamNotifications?.SendNotification("Connected to Beat Saber", 1000, MessageType.INFO); + + if (msg.Type != ReconnectionType.Initial) + _logger.LogInfo($"Beat Saber Connection via BeatSaberPlus re-established: {msg.Type}"); + + LastWarning = ConnectionTypeWarning.Connected; + } + + internal override bool GetIsRunning() => socket?.IsRunning ?? false; +} diff --git a/BeatRecorder/Util/BeatSaber/DataPullerHandler.cs b/BeatRecorder/Util/BeatSaber/DataPullerHandler.cs new file mode 100644 index 0000000..e7c2594 --- /dev/null +++ b/BeatRecorder/Util/BeatSaber/DataPullerHandler.cs @@ -0,0 +1,268 @@ +using BeatRecorder.Entities; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.BeatSaber; + +internal class DataPullerHandler : BaseBeatSaberHandler +{ + private Program Program = null; + + private WebsocketClient mainSocket { get; set; } + private WebsocketClient dataSocket { get; set; } + + internal SharedStatus CurrentStatus => new(CurrentMain, CurrentData, CurrentMaxCombo, this); + internal SharedStatus LastCompletedStatus { get; set; } + + private DataPullerMain CurrentMain = null; + private DataPullerData CurrentData = null; + + ConnectionTypeWarning LastWarning = ConnectionTypeWarning.Connected; + + int CurrentMaxCombo = 0; + private bool InLevel = false; + private bool IsPaused = false; + + public override BaseBeatSaberHandler Initialize(Program program) + { + _logger.LogInfo("Initializing Connection to Beat Saber via DataPuller.."); + + this.Program = program; + + var factory = new Func(() => new ClientWebSocket + { + Options = + { + KeepAliveInterval = TimeSpan.FromSeconds(5) + } + }); + + Task mainConn = Task.Run(() => + { + mainSocket = new WebsocketClient(new Uri($"ws://{Program.LoadedConfig.BeatSaberUrl}:{Program.LoadedConfig.BeatSaberPort}/BSDataPuller/MapData"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3) + }; + + mainSocket.MessageReceived.Subscribe(msg => { MainMessageRecieved(msg.Text); }); + mainSocket.ReconnectionHappened.Subscribe(type => { Reconnected(type); }); + mainSocket.DisconnectionHappened.Subscribe(type => { Disconnected(type); }); + + mainSocket.Start().Wait(); + + while (!mainSocket.IsRunning) + Thread.Sleep(50); + + _logger.LogInfo($"Connected to Beat Saber via DataPuller Main Socket"); + }); + + Task dataConn = Task.Run(() => + { + dataSocket = new WebsocketClient(new Uri($"ws://{Program.LoadedConfig.BeatSaberUrl}:{Program.LoadedConfig.BeatSaberPort}/BSDataPuller/LiveData"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3) + }; + + dataSocket.MessageReceived.Subscribe(msg => { DataMessageRecieved(msg.Text); }); + dataSocket.ReconnectionHappened.Subscribe(type => { Reconnected(type); }); + dataSocket.DisconnectionHappened.Subscribe(type => { Disconnected(type); }); + + dataSocket.Start().Wait(); + + while (!dataSocket.IsRunning) + Thread.Sleep(50); + + _logger.LogInfo($"Connected to Beat Saber via DataPuller Data Socket"); + }); + + while (!mainConn.IsCompleted || !dataConn.IsCompleted) + Thread.Sleep(50); + + return this; + } + + public override SharedStatus GetCurrentStatus() => CurrentStatus; + public override SharedStatus GetLastCompletedStatus() => LastCompletedStatus; + + private void MainMessageRecieved(string text) + { + DataPullerMain _status; + + try + { + _status = JsonConvert.DeserializeObject(text); + } + catch (Exception ex) + { + _logger.LogFatal($"Unable to convert message into object", ex); + return; + } + + if (InLevel != _status.InLevel) + { + if (!InLevel && _status.InLevel) + { + InLevel = true; + _logger.LogInfo($"Started playing \"{_status.SongName}\" by \"{_status.SongAuthor}\""); + + CurrentMain = _status; + CurrentMaxCombo = 0; + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + + _ = Program.ObsClient.StartRecording(); + } + else if (InLevel && !_status.InLevel) + { + Thread.Sleep(500); + InLevel = false; + IsPaused = false; + + _logger.LogInfo($"Stopped playing \"{_status.SongName}\" by \"{_status.SongAuthor}\""); + + CurrentMain = _status; + + CurrentStatus.Update(new SharedStatus(CurrentMain, CurrentData, CurrentMaxCombo, this)); + LastCompletedStatus = CurrentStatus.Clone(); + + _ = Program.ObsClient.StopRecording(); + + if (!Program.LoadedConfig.OBSMenuScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSMenuScene); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + } + } + + if (_status.InLevel) + { + if (IsPaused != _status.LevelPaused) + { + if (IsPaused && _status.LevelPaused) + { + IsPaused = true; + _logger.LogInfo("Song paused."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.PauseRecording(); + + if (!Program.LoadedConfig.OBSPauseScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSPauseScene); + } + else if (IsPaused && !_status.LevelPaused) + { + IsPaused = false; + _logger.LogInfo("Song resumed."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + } + } + } + } + + private void DataMessageRecieved(string text) + { + DataPullerData _status; + + try + { + _status = JsonConvert.DeserializeObject(text); + } + catch (Exception ex) + { + _logger.LogFatal($"Unable to convert message into object", ex); + return; + } + + if (InLevel && (CurrentData?.unixTimestamp ?? 0) < _status.unixTimestamp) + CurrentData = _status; + + if (CurrentMaxCombo < _status.Combo) + CurrentMaxCombo = _status.Combo; + } + + private void Disconnected(DisconnectionInfo msg) + { + try + { + Process[] processCollection = Process.GetProcesses(); + + if (!processCollection.Any(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber"))) + { + if (LastWarning != ConnectionTypeWarning.NoProcess) + { + _logger.LogWarn($"Couldn't find a BeatSaber process, is BeatSaber started? ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NoProcess; + } + else + { + bool FoundWebSocketDll = false; + + string InstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber")).MainModule.FileName; + InstallationDirectory = InstallationDirectory.Remove(InstallationDirectory.LastIndexOf("\\"), InstallationDirectory.Length - InstallationDirectory.LastIndexOf("\\")); + + if (Directory.GetDirectories(InstallationDirectory).Any(x => x.ToLower().EndsWith("plugins"))) + { + if (Directory.GetFiles($"{InstallationDirectory}\\Plugins").Any(x => x.Contains("DataPuller") && x.EndsWith(".dll"))) + { + FoundWebSocketDll = true; + } + } + else + { + if (LastWarning != ConnectionTypeWarning.NotModded) + { + _logger.LogFatal($"Beat Saber seems to be running but the BSDataPuller modifaction doesn't seem to be installed. Is your game even modded? (If haven't modded it, please do it: https://bit.ly/2TAvenk. If already modded, install BSDataPuller: https://bit.ly/3mcvC7g) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NotModded; + return; + } + + if (FoundWebSocketDll) + { + if (LastWarning != ConnectionTypeWarning.ModInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running and the BSDataPuller modifaction seems to be installed. Please make sure you put in the right port and you installed all of BSDataPuller' dependiencies! (If not installed, please install it: https://bit.ly/3mcvC7g) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModInstalled; + } + else + { + if (LastWarning != ConnectionTypeWarning.ModNotInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running but the BSDataPuller modifaction doesn't seem to be installed. Please make sure to install BSDataPuller! (If not installed, please install it: https://bit.ly/3mcvC7g) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModNotInstalled; + } + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to check if BSDataPuller is installed: (Disconnect Reason: {msg.Type}) {ex}"); + } + } + + private void Reconnected(ReconnectionInfo msg) + { + Program.steamNotifications?.SendNotification("Connected to Beat Saber", 1000, MessageType.INFO); + + if (msg.Type != ReconnectionType.Initial) + _logger.LogInfo($"Beat Saber Connection via DataPuller re-established: {msg.Type}"); + + LastWarning = ConnectionTypeWarning.Connected; + } + + internal override bool GetIsRunning() => (mainSocket?.IsRunning ?? false) && (dataSocket?.IsRunning ?? false); +} diff --git a/BeatRecorder/Util/BeatSaber/HttpStatusHandler.cs b/BeatRecorder/Util/BeatSaber/HttpStatusHandler.cs new file mode 100644 index 0000000..ecbd866 --- /dev/null +++ b/BeatRecorder/Util/BeatSaber/HttpStatusHandler.cs @@ -0,0 +1,231 @@ +using BeatRecorder.Entities; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.BeatSaber; + +internal class HttpStatusHandler : BaseBeatSaberHandler +{ + private WebsocketClient socket { get; set; } + + ConnectionTypeWarning LastWarning = ConnectionTypeWarning.Connected; + + private Program Program = null; + internal SharedStatus CurrentStatus => new(Current.status, this); + internal SharedStatus LastCompletedStatus { get; set; } + + private HttpStatus Current = null; + + public override BaseBeatSaberHandler Initialize(Program program) + { + _logger.LogInfo("Initializing Connection to Beat Saber via HttpStatus.."); + + this.Program = program; + + var factory = new Func(() => new ClientWebSocket + { + Options = + { + KeepAliveInterval = TimeSpan.FromSeconds(5) + } + }); + + socket = new WebsocketClient(new Uri($"ws://{program.LoadedConfig.BeatSaberUrl}:{program.LoadedConfig.BeatSaberPort}/socket"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3) + }; + + socket.MessageReceived.Subscribe(msg => { MessageReceived(msg.Text); }); + socket.ReconnectionHappened.Subscribe(type => { Reconnected(type); }); + socket.DisconnectionHappened.Subscribe(type => { Disconnected(type); }); + + socket.Start().Wait(); + return this; + } + + public override SharedStatus GetCurrentStatus() => CurrentStatus; + public override SharedStatus GetLastCompletedStatus() => LastCompletedStatus; + + internal void MessageReceived(string e) + { + HttpStatus _status; + + try + { + _status = JsonConvert.DeserializeObject(e); + } + catch (Exception ex) + { + _logger.LogFatal($"Unable to convert message into object", ex); + return; + } + + switch (_status.@event) + { + case "hello": + { + _logger.LogInfo($"Connected to Beat Saber via Http Status"); + + Current = _status; + + if (!Program.LoadedConfig.OBSMenuScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSMenuScene); + break; + } + + case "songStart": + { + _logger.LogInfo($"Started playing \"{_status.status.beatmap.songName}\" by \"{_status.status.beatmap.songAuthorName}\""); + + Current = _status; + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + + _ = Program.ObsClient.StartRecording(); + break; + } + + case "finished": + { + _logger.LogInfo($"Song finished."); + Current.status.performance.finished = true; + break; + } + + case "failed": + { + _logger.LogInfo($"Song failed."); + Current.status.performance.failed = true; + break; + } + + case "pause": + { + _logger.LogInfo($"Song paused."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.PauseRecording(); + + if (!Program.LoadedConfig.OBSPauseScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSPauseScene); + + break; + } + + case "resume": + { + _logger.LogInfo($"Song resumed."); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + + if (!Program.LoadedConfig.OBSIngameScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSIngameScene); + + break; + } + + case "scoreChanged": + { + Current.status.performance = _status.status.performance; + break; + } + + case "menu": + { + CurrentStatus.Update(new SharedStatus(_status.status, this)); + LastCompletedStatus = CurrentStatus.Clone(); + _logger.LogInfo($"Stopped playing \"{LastCompletedStatus.BeatmapInfo.NameWithSub}\" by \"{LastCompletedStatus.BeatmapInfo.Author}\""); + Current = _status; + _ = Program.ObsClient.StopRecording(); + + if (!Program.LoadedConfig.OBSMenuScene.IsNullOrWhiteSpace()) + Program.ObsClient.SetCurrentScene(Program.LoadedConfig.OBSMenuScene); + + if (Program.LoadedConfig.PauseRecordingOnIngamePause) + Program.ObsClient.ResumeRecording(); + + break; + } + } + } + + internal void Reconnected(ReconnectionInfo msg) + { + Program.steamNotifications?.SendNotification("Connected to Beat Saber", 1000, MessageType.INFO); + + if (msg.Type != ReconnectionType.Initial) + _logger.LogInfo($"Beat Saber Connection via Http Status re-established: {msg.Type}"); + + LastWarning = ConnectionTypeWarning.Connected; + } + + internal void Disconnected(DisconnectionInfo msg) + { + try + { + Process[] processCollection = Process.GetProcesses(); + + if (!processCollection.Any(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber"))) + { + if (LastWarning != ConnectionTypeWarning.NoProcess) + { + _logger.LogWarn($"Couldn't find a BeatSaber process, is BeatSaber started? ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NoProcess; + } + else + { + bool FoundWebSocketDll = false; + + string InstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().Replace(" ", "").StartsWith("beatsaber")).MainModule.FileName; + InstallationDirectory = InstallationDirectory.Remove(InstallationDirectory.LastIndexOf("\\"), InstallationDirectory.Length - InstallationDirectory.LastIndexOf("\\")); + + if (Directory.GetDirectories(InstallationDirectory).Any(x => x.ToLower().EndsWith("plugins"))) + { + if (Directory.GetFiles($"{InstallationDirectory}\\Plugins").Any(x => x.Contains("HTTPStatus") && x.EndsWith(".dll"))) + { + FoundWebSocketDll = true; + } + } + else + { + if (LastWarning != ConnectionTypeWarning.NotModded) + { + _logger.LogFatal($"Beat Saber seems to be running but the beatsaber-http-status modifaction doesn't seem to be installed. Is your game even modded? (If haven't modded it, please do it: https://bit.ly/2TAvenk. If already modded, install beatsaber-http-status: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.NotModded; + return; + } + + if (FoundWebSocketDll) + { + if (LastWarning != ConnectionTypeWarning.ModInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running and the beatsaber-http-status modifaction seems to be installed. Please make sure you put in the right port and you installed all of beatsaber-http-status' dependiencies! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModInstalled; + } + else + { + if (LastWarning != ConnectionTypeWarning.ModNotInstalled) + { + _logger.LogFatal($"Beat Saber seems to be running but the beatsaber-http-status modifaction doesn't seem to be installed. Please make sure to install beatsaber-http-status! (If not installed, please install it: https://bit.ly/3wYX3Dd) ({msg.Type})"); + Program.steamNotifications?.SendNotification("Disconnected from Beat Saber", 1000, MessageType.ERROR); + } + LastWarning = ConnectionTypeWarning.ModNotInstalled; + } + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to check if beatsaber-http-status is installed: (Disconnect Reason: {msg.Type}) {ex}"); + } + } + + internal override bool GetIsRunning() => socket?.IsRunning ?? false; +} diff --git a/BeatRecorder/Util/ConsoleHelper.cs b/BeatRecorder/Util/ConsoleHelper.cs index d72f66a..d5c1166 100644 --- a/BeatRecorder/Util/ConsoleHelper.cs +++ b/BeatRecorder/Util/ConsoleHelper.cs @@ -7,4 +7,4 @@ public class ConsoleHelper [DllImport("user32.dll")] internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -} +} \ No newline at end of file diff --git a/BeatRecorder/Util/EasyOpenVR b/BeatRecorder/Util/EasyOpenVR deleted file mode 160000 index a626c8e..0000000 --- a/BeatRecorder/Util/EasyOpenVR +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a626c8e9d29511f2c88c998278093da06a71c9a6 diff --git a/BeatRecorder/Util/Extensions.cs b/BeatRecorder/Util/Extensions.cs index 6d20336..7aa612b 100644 --- a/BeatRecorder/Util/Extensions.cs +++ b/BeatRecorder/Util/Extensions.cs @@ -1,4 +1,4 @@ -namespace BeatRecorder; +namespace BeatRecorder.Util; internal static class Extensions { @@ -16,56 +16,4 @@ internal static string HashEncode(this string input) return System.Convert.ToBase64String(hash); } - - internal static string GetShortTimeFormat(this TimeSpan _timespan, TimeFormat timeFormat) - { - switch (timeFormat) - { - case TimeFormat.HOURS: - if (_timespan.TotalDays >= 1) - return $"{(Math.Floor(_timespan.TotalHours).ToString().Length == 1 ? $"0{Math.Floor(_timespan.TotalHours)}" : Math.Floor(_timespan.TotalHours))}:" + - $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - if (_timespan.TotalHours >= 1) - return $"{(_timespan.Hours.ToString().Length == 1 ? $"0{_timespan.Hours}" : _timespan.Hours)}:" + - $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - return $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - case TimeFormat.DAYS: - if (_timespan.TotalDays >= 1) - return $"{(Math.Floor(_timespan.TotalDays).ToString().Length == 1 ? $"0{Math.Floor(_timespan.TotalDays)}" : Math.Floor(_timespan.TotalDays))}" + - $"{(_timespan.Hours.ToString().Length == 1 ? $"0{_timespan.Hours}" : _timespan.Hours)}:" + - $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - if (_timespan.TotalHours >= 1) - return $"{(Math.Floor(_timespan.TotalHours).ToString().Length == 1 ? $"0{Math.Floor(_timespan.TotalHours)}" : Math.Floor(_timespan.TotalHours))}:" + - $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - return $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - case TimeFormat.MINUTES: - if (_timespan.TotalHours >= 1) - return $"{(Math.Floor(_timespan.TotalMinutes).ToString().Length == 1 ? $"0{Math.Floor(_timespan.TotalMinutes)}" : Math.Floor(_timespan.TotalMinutes))}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - return $"{(_timespan.Minutes.ToString().Length == 1 ? $"0{_timespan.Minutes}" : _timespan.Minutes)}:" + - $"{(_timespan.Seconds.ToString().Length == 1 ? $"0{_timespan.Seconds}" : _timespan.Seconds)}"; - - default: - return _timespan.ToString(); - } - } - - public enum TimeFormat - { - MINUTES, - HOURS, - DAYS - } } diff --git a/BeatRecorder/Util/Log.cs b/BeatRecorder/Util/Log.cs new file mode 100644 index 0000000..3646d62 --- /dev/null +++ b/BeatRecorder/Util/Log.cs @@ -0,0 +1,6 @@ +namespace BeatRecorder; + +internal class Log +{ + internal static Logger _logger { get; set; } +} diff --git a/BeatRecorder/Util/OBS/BaseObsHandler.cs b/BeatRecorder/Util/OBS/BaseObsHandler.cs new file mode 100644 index 0000000..3b8710b --- /dev/null +++ b/BeatRecorder/Util/OBS/BaseObsHandler.cs @@ -0,0 +1,15 @@ +namespace BeatRecorder.Util.OBS; + +public abstract class BaseObsHandler +{ + internal abstract Task StartRecording(); + internal abstract Task StopRecording(bool ForceStop = false); + internal abstract void PauseRecording(); + internal abstract void ResumeRecording(); + internal abstract void SetCurrentScene(string scene); + + internal abstract bool GetIsRunning(); + internal abstract bool GetIsRecording(); + internal abstract bool GetIsPaused(); + internal abstract int GetRecordingSeconds(); +} diff --git a/BeatRecorder/Util/OBS/LegacyObsHandler.cs b/BeatRecorder/Util/OBS/LegacyObsHandler.cs new file mode 100644 index 0000000..c57541b --- /dev/null +++ b/BeatRecorder/Util/OBS/LegacyObsHandler.cs @@ -0,0 +1,430 @@ +using BeatRecorder.Entities.OBS.Legacy; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.OBS; + +internal class LegacyObsHandler : BaseObsHandler +{ + private LegacyObsHandler() { } + + private WebsocketClient socket { get; set; } = null; + + ConnectionTypeWarning LastWarning = ConnectionTypeWarning.Connected; + + private readonly string RequiredAuthenticationGuid = Guid.NewGuid().ToString(); + private readonly string AuthenticationGuid = Guid.NewGuid().ToString(); + + private bool InitialConnectionCompleted = false; + + internal bool IsRecording { get; private set; } = false; + internal bool IsPaused { get; private set; } = false; + internal int RecordingSeconds { get; private set; } = 0; + + internal CancellationTokenSource StopRecordingDelayCancel = new(); + + private Program Program = null; + + internal static BaseObsHandler Initialize(Program program) + { + _logger.LogInfo("Initializing Connection to OBS.."); + + LegacyObsHandler obsHandler = new() + { + Program = program + }; + + var factory = new Func(() => new ClientWebSocket + { + Options = + { + KeepAliveInterval = TimeSpan.FromSeconds(5) + } + }); + + obsHandler.socket = new WebsocketClient(new Uri($"ws://{obsHandler.Program.LoadedConfig.OBSUrl}:{obsHandler.Program.LoadedConfig.OBSPortLegacy}"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3) + }; + + obsHandler.socket.MessageReceived.Subscribe(msg => { _ = obsHandler.MessageReceived(msg); }); + obsHandler.socket.ReconnectionHappened.Subscribe(type => { obsHandler.Reconnected(type); }); + obsHandler.socket.DisconnectionHappened.Subscribe(type => { obsHandler.Disconnected(type); }); + + obsHandler.socket.Start().Wait(); + + while (!obsHandler.socket.IsRunning) + Thread.Sleep(50); + + _logger.LogInfo("Connection with OBS established."); + + var message = new GetAuthRequiredRequest(obsHandler.RequiredAuthenticationGuid).Build(); + _logger.LogTrace(message); + + obsHandler.socket.Send(message); + + obsHandler.InitialConnectionCompleted = true; + + return obsHandler; + } + + internal override async Task StartRecording() + { + if (!Program.LoadedConfig.AutomaticRecording) + return; + + if (!socket.IsRunning) + throw new ArgumentException("Connection with OBS is not established."); + + if (IsRecording) + { + await StopRecording(true); + + while (IsRecording) + { + Thread.Sleep(20); + } + } + + if (Program.LoadedConfig.MininumWaitUntilRecordingCanStart < 200 || Program.LoadedConfig.MininumWaitUntilRecordingCanStart > 2000) + { + _logger.LogWarn("MininumWaitUntilRecordingCanStart was reset to 800. Allowed range for value is between 200 and 2000"); + Program.LoadedConfig.MininumWaitUntilRecordingCanStart = 800; + } + + Thread.Sleep(Program.LoadedConfig.MininumWaitUntilRecordingCanStart); + socket.Send(new StartRecordingRequest().Build()); + } + + internal override async Task StopRecording(bool ForceStop = false) + { + if (!Program.LoadedConfig.AutomaticRecording) + return; + + if (!socket.IsRunning) + throw new ArgumentException("Connection with OBS is not established."); + + if (!ForceStop) + { + if (Program.LoadedConfig.StopRecordingDelay < 0 || Program.LoadedConfig.StopRecordingDelay > 20) + { + _logger.LogWarn("StopRecordingDelay was reset to 5. Allowed range for value is between 0 and 20"); + Program.LoadedConfig.StopRecordingDelay = 5; + } + + try + { + var millisecondsDelay = Program.LoadedConfig.StopRecordingDelay; + await Task.Delay((millisecondsDelay <= 0 ? 1 : millisecondsDelay) * 1000, this.StopRecordingDelayCancel.Token); + } + catch (OperationCanceledException) + { + return; + } + } + else + { + StopRecordingDelayCancel.Cancel(); + StopRecordingDelayCancel = new(); + } + + socket.Send(new StopRecordingRequest().Build()); + } + + internal override void PauseRecording() + { + socket.Send(new PauseRecordingRequest().Build()); + } + + internal override void ResumeRecording() + { + socket.Send(new ResumeRecordingRequest().Build()); + } + + internal override void SetCurrentScene(string scene) + { + socket.Send(new SetCurrentScene(scene).Build()); + } + + private async Task MessageReceived(ResponseMessage msg) + { + _logger.LogTrace(msg.Text); + var Message = JsonConvert.DeserializeObject(msg.Text); + + if (Message.MessageId == RequiredAuthenticationGuid) + { + AuthenticationRequired required = JsonConvert.DeserializeObject(msg.Text); + + if (required.authRequired) + { + if (Program.LoadedConfig.OBSPassword.IsNullOrWhiteSpace()) + { + if (Program.LoadedConfig.DisplayUI) + { + Thread.Sleep(3000); + + Program.GUI.ShowNotification("A password is required to log into your obs websocket.", "Error", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); + Program.GUI.ShowSettings(true); + return; + } + + await Task.Delay(1000); + _logger.LogInfo("A password is required to log into your obs websocket."); + await Task.Delay(1000); + Console.Write("> "); + + // I was to lazy to write my own.. https://stackoverflow.com/questions/3404421/password-masking-console-application + + string Password = ""; + + ConsoleKey key = ConsoleKey.A; + + while (key != ConsoleKey.Enter || key != ConsoleKey.Escape) + { + var keyInfo = Console.ReadKey(intercept: true); + key = keyInfo.Key; + + if (key == ConsoleKey.Backspace && Password.Length > 0) + { + Console.Write("\b \b"); + Password = Password[0..^1]; + } + else if (!char.IsControl(keyInfo.KeyChar)) + { + Console.Write("*"); + Password += keyInfo.KeyChar; + } + else if (key == ConsoleKey.Escape) + { + _logger.LogError("Cancelled password input. Cannot continue without."); + await Task.Delay(1000); + Environment.Exit(0); + return; + } + else if (key == ConsoleKey.Enter) + { + Console.Write("\r \r"); + break; + } + } + + if (key == ConsoleKey.Enter) + { + if (Program.LoadedConfig.AskToSaveOBSPassword) + { + key = ConsoleKey.A; + + _logger.LogWarn("Do you want to save this password in the config? (THIS WILL STORE THE PASSWORD IN PLAIN-TEXT, THIS CAN BE ACCESSED BY ANYONE WITH ACCESS TO YOUR FILES. THIS IS NOT RECOMMENDED!)"); + while (key != ConsoleKey.Enter || key != ConsoleKey.Escape || key != ConsoleKey.Y || key != ConsoleKey.N) + { + await Task.Delay(1000); + Console.Write("y/N > "); + + var keyInfo = Console.ReadKey(intercept: true); + Console.Write("\r \r"); + key = keyInfo.Key; + + if (key == ConsoleKey.Escape) + { + _logger.LogWarn("Cancelled. Press any key to exit."); + Console.ReadKey(); + Environment.Exit(0); + return; + } + else if (key == ConsoleKey.Y) + { + _logger.LogInfo("Your password is now saved in the Settings.json."); + Program.LoadedConfig.OBSPassword = Password; + Program.LoadedConfig.AskToSaveOBSPassword = true; + + File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedConfig, Formatting.Indented)); + break; + } + else if (key == ConsoleKey.N || key == ConsoleKey.Enter) + { + _logger.LogInfo("Your password will not be saved. This wont be asked in the future."); + _logger.LogInfo("To re-enable this prompt, set AskToSaveOBSPassword to true in the Settings.json."); + Program.LoadedConfig.OBSPassword = ""; + Program.LoadedConfig.AskToSaveOBSPassword = false; + + File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedConfig, Formatting.Indented)); + break; + } + } + } + + Program.LoadedConfig.OBSPassword = Password; + } + } + + _logger.LogInfo("Connection with OBS requires authentication. Attempting log in.."); + + string secret = Extensions.HashEncode(Program.LoadedConfig.OBSPassword + required.salt); + string authResponse = Extensions.HashEncode(secret + required.challenge); + + socket.Send(new AuthenticateRequest(authResponse, AuthenticationGuid).Build()); + } + } + else if (Message.MessageId == AuthenticationGuid) + { + if (Message.Status == "ok") + { + _logger.LogInfo("Authentication with OBS successful."); + Program.steamNotifications?.SendNotification("Connected to OBS", 1000, MessageType.INFO); + } + else + { + _logger.LogError("Failed to authenticate with OBS. Please check your password or wait a few seconds to automatically retry authenticating."); + await socket.Stop(WebSocketCloseStatus.NormalClosure, "Shutting down"); + + await Task.Delay(2000); + _logger.LogInfo("Re-trying authentication with OBS.."); + await socket.Start(); + + var message = new GetAuthRequiredRequest(RequiredAuthenticationGuid).Build(); + _logger.LogTrace(message); + } + } + else + { + _logger.LogTrace($"Received unknown message id: {Message.MessageId}"); + } + + if (Message.UpdateType is null) + return; + + if (Message.UpdateType == "RecordingStarted") + { + Program.steamNotifications?.SendNotification("Recording started", 1000, MessageType.INFO); + + IsRecording = true; + + _logger.LogInfo("Recording started."); + + while (IsRecording) + { + if (!IsPaused) + RecordingSeconds++; + + await Task.Delay(1000); + } + await Task.Delay(2000); + RecordingSeconds = 0; + } + else if (Message.UpdateType == "RecordingStopped") + { + Program.steamNotifications?.SendNotification("Recording stopped", 1000, MessageType.INFO); + + IsRecording = false; + IsPaused = false; + + _logger.LogInfo("Recording stopped."); + + RecordingStopped RecordingStopped = JsonConvert.DeserializeObject(msg.Text); + Program.BeatSaberClient.HandleFile(RecordingStopped.recordingFilename, RecordingSeconds, Program.BeatSaberClient.GetLastCompletedStatus(), Program); + } + else if (Message.UpdateType == "RecordingPaused") + { + Program.steamNotifications?.SendNotification("Recording paused", 1000, MessageType.INFO); + + IsPaused = true; + + _logger.LogInfo("Recording paused."); + } + else if (Message.UpdateType == "RecordingResumed") + { + Program.steamNotifications?.SendNotification("Recording resumed", 1000, MessageType.INFO); + + IsPaused = false; + + _logger.LogInfo("Recording resumed."); + } + else + { + _logger.LogTrace($"Received unknown update type: {Message.MessageId}"); + } + } + + private void Reconnected(ReconnectionInfo msg) + { + if (msg.Type != ReconnectionType.Initial) + { + if (InitialConnectionCompleted) + { + var message = new GetAuthRequiredRequest(RequiredAuthenticationGuid).Build(); + _logger.LogTrace(message); + + socket.Send(message); + } + } + + LastWarning = ConnectionTypeWarning.Connected; + } + + private void Disconnected(DisconnectionInfo msg) + { + Program.steamNotifications?.SendNotification("Disconnected from OBS", 1000, MessageType.ERROR); + + try + { + Process[] processCollection = Process.GetProcesses(); + + if (!processCollection.Any(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32"))) + { + if (LastWarning != ConnectionTypeWarning.NoProcess) + { + _logger.LogWarn($"Couldn't find an OBS process, is your OBS running? ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.NoProcess; + } + else + { + bool FoundWebSocketDll = false; + + string OBSInstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32")).MainModule.FileName; + OBSInstallationDirectory = OBSInstallationDirectory.Remove(OBSInstallationDirectory.LastIndexOf("\\bin"), OBSInstallationDirectory.Length - OBSInstallationDirectory.LastIndexOf("\\bin")); + + if (Directory.GetDirectories(OBSInstallationDirectory).Any(x => x.ToLower().EndsWith("obs-plugins"))) + { + foreach (var b in Directory.GetDirectories($"{OBSInstallationDirectory}\\obs-plugins")) + { + if (Directory.GetFiles(b).Any(x => x.Contains("obs-websocket") && x.EndsWith(".dll"))) + { + FoundWebSocketDll = true; + break; + } + } + } + + if (FoundWebSocketDll) + { + if (LastWarning != ConnectionTypeWarning.ModInstalled) + { + _logger.LogFatal($"OBS seems to be running but the obs-websocket server isn't running. Please make sure you have the obs-websocket server activated! (Tools -> WebSocket Server Settings) ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.ModInstalled; + } + else + { + if (LastWarning != ConnectionTypeWarning.ModNotInstalled) + { + _logger.LogFatal($"OBS seems to be running but the obs-websocket server isn't installed. Please make sure you have the obs-websocket server installed! (To install, follow this link: https://bit.ly/3BCXfeS) ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.ModNotInstalled; + } + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to check if obs-websocket is installed: (Disconnect Reason: {msg.Type}) {ex}"); + } + } + + internal override bool GetIsRunning() => socket?.IsRunning ?? false; + + internal override bool GetIsRecording() => IsRecording; + + internal override bool GetIsPaused() => IsPaused; + + internal override int GetRecordingSeconds() => RecordingSeconds; +} diff --git a/BeatRecorder/Util/OBS/ObsHandler.cs b/BeatRecorder/Util/OBS/ObsHandler.cs new file mode 100644 index 0000000..35b02c1 --- /dev/null +++ b/BeatRecorder/Util/OBS/ObsHandler.cs @@ -0,0 +1,431 @@ +using BeatRecorder.Entities.OBS; +using BeatRecorder.Enums; +using Newtonsoft.Json.Linq; + +namespace BeatRecorder.Util.OBS; + +internal class ObsHandler : BaseObsHandler +{ + private WebsocketClient socket { get; set; } = null; + + ConnectionTypeWarning LastWarning = ConnectionTypeWarning.Connected; + + internal bool IsRecording { get; private set; } = false; + internal bool IsPaused { get; private set; } = false; + internal int RecordingSeconds { get; private set; } = 0; + + internal CancellationTokenSource StopRecordingDelayCancel = new(); + + private Program Program = null; + + bool AttemptedToIdentify = false; + + internal static BaseObsHandler Initialize(Program program) + { + _logger.LogInfo("Initializing Connection to OBS.."); + + ObsHandler obsHandler = new() + { + Program = program + }; + + var factory = new Func(() => new ClientWebSocket + { + Options = + { + KeepAliveInterval = TimeSpan.FromSeconds(5) + } + }); + + obsHandler.socket = new WebsocketClient(new Uri($"ws://{obsHandler.Program.LoadedConfig.OBSUrl}:{obsHandler.Program.LoadedConfig.OBSPortModern}"), factory) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(3), + IsReconnectionEnabled = false, + }; + + obsHandler.socket.MessageReceived.Subscribe(msg => { _ = obsHandler.MessageReceived(msg); }); + obsHandler.socket.ReconnectionHappened.Subscribe(type => { obsHandler.Reconnected(type); }); + obsHandler.socket.DisconnectionHappened.Subscribe(type => { obsHandler.Disconnected(type); }); + + obsHandler.socket.Start().Wait(); + + return obsHandler; + } + + private async Task MessageReceived(ResponseMessage msg) + { + _logger.LogTrace(msg.Text); + + ObsResponse obsResponse = JsonConvert.DeserializeObject(msg.Text); + + switch (obsResponse.op) + { + case 0: + { + Hello required = JsonConvert.DeserializeObject(msg.Text); + + if (required.d.authentication is not null) + { + if (Program.LoadedConfig.OBSPassword.IsNullOrWhiteSpace()) + { + if (Program.LoadedConfig.DisplayUI) + { + Thread.Sleep(3000); + + Program.GUI.ShowNotification("A password is required to log into your obs websocket.", "Error", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); + Program.GUI.ShowSettings(true); + return; + } + + await Task.Delay(1000); + _logger.LogInfo("A password is required to log into your obs websocket."); + await Task.Delay(1000); + Console.Write("> "); + + // I was to lazy to write my own.. https://stackoverflow.com/questions/3404421/password-masking-console-application + + string Password = ""; + + ConsoleKey key = ConsoleKey.A; + + while (key != ConsoleKey.Enter || key != ConsoleKey.Escape) + { + var keyInfo = Console.ReadKey(intercept: true); + key = keyInfo.Key; + + if (key == ConsoleKey.Backspace && Password.Length > 0) + { + Console.Write("\b \b"); + Password = Password[0..^1]; + } + else if (!char.IsControl(keyInfo.KeyChar)) + { + Console.Write("*"); + Password += keyInfo.KeyChar; + } + else if (key == ConsoleKey.Escape) + { + _logger.LogError("Cancelled password input. Cannot continue without."); + await Task.Delay(1000); + Environment.Exit(0); + return; + } + else if (key == ConsoleKey.Enter) + { + Console.Write("\r \r"); + break; + } + } + + if (key == ConsoleKey.Enter) + { + if (Program.LoadedConfig.AskToSaveOBSPassword) + { + key = ConsoleKey.A; + + _logger.LogWarn("Do you want to save this password in the config? (THIS WILL STORE THE PASSWORD IN PLAIN-TEXT, THIS CAN BE ACCESSED BY ANYONE WITH ACCESS TO YOUR FILES. THIS IS NOT RECOMMENDED!)"); + while (key != ConsoleKey.Enter || key != ConsoleKey.Escape || key != ConsoleKey.Y || key != ConsoleKey.N) + { + await Task.Delay(1000); + Console.Write("y/N > "); + + var keyInfo = Console.ReadKey(intercept: true); + Console.Write("\r \r"); + key = keyInfo.Key; + + if (key == ConsoleKey.Escape) + { + _logger.LogWarn("Cancelled. Press any key to exit."); + Console.ReadKey(); + Environment.Exit(0); + return; + } + else if (key == ConsoleKey.Y) + { + _logger.LogInfo("Your password is now saved in the Settings.json."); + Program.LoadedConfig.OBSPassword = Password; + Program.LoadedConfig.AskToSaveOBSPassword = true; + + File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedConfig, Formatting.Indented)); + break; + } + else if (key == ConsoleKey.N || key == ConsoleKey.Enter) + { + _logger.LogInfo("Your password will not be saved. This wont be asked in the future."); + _logger.LogInfo("To re-enable this prompt, set AskToSaveOBSPassword to true in the Settings.json."); + Program.LoadedConfig.OBSPassword = ""; + Program.LoadedConfig.AskToSaveOBSPassword = false; + + File.WriteAllText("Settings.json", JsonConvert.SerializeObject(Program.LoadedConfig, Formatting.Indented)); + break; + } + } + } + + Program.LoadedConfig.OBSPassword = Password; + } + } + + _logger.LogInfo("Connection with OBS requires authentication. Identifying.."); + + string secret = Extensions.HashEncode(Program.LoadedConfig.OBSPassword + required.d.authentication.salt); + string authResponse = Extensions.HashEncode(secret + required.d.authentication.challenge); + + AttemptedToIdentify = true; + socket.Send(new Indentify(authResponse).Build()); + } + else + { + _logger.LogInfo("Connection with OBS does not require authentication. Identifying.."); + + AttemptedToIdentify = true; + socket.Send(new Indentify().Build()); + } + + break; + } + case 2: + { + Indentified indentified = JsonConvert.DeserializeObject(msg.Text); + if (indentified.d.negotiatedRpcVersion != 1) + _logger.LogWarn("Negotiated Rpc Version does not match 1. Please expect possible bugs."); + + Program.steamNotifications?.SendNotification("Connected to OBS", 1000, MessageType.INFO); + + _logger.LogInfo("Successfully identified to websocket."); + AttemptedToIdentify = false; + break; + } + case 5: + { + EventType eventType = JsonConvert.DeserializeObject(msg.Text); + + switch (eventType.d.eventType) + { + case "RecordStateChanged": + { + RecordStateChanged recordStateChanged = JsonConvert.DeserializeObject(msg.Text); + + switch (recordStateChanged.d.eventData.outputState) + { + case "OBS_WEBSOCKET_OUTPUT_STARTED": + { + Program.steamNotifications?.SendNotification("Recording started", 1000, MessageType.INFO); + + IsRecording = true; + + _logger.LogInfo("Recording started."); + + while (IsRecording) + { + if (!IsPaused) + RecordingSeconds++; + + await Task.Delay(1000); + } + await Task.Delay(2000); + RecordingSeconds = 0; + break; + } + case "OBS_WEBSOCKET_OUTPUT_STOPPED": + { + Program.steamNotifications?.SendNotification("Recording stopped", 1000, MessageType.INFO); + + IsRecording = false; + IsPaused = false; + + _logger.LogInfo("Recording stopped."); + + Program.BeatSaberClient.HandleFile(recordStateChanged.d.eventData.outputPath, RecordingSeconds, Program.BeatSaberClient.GetLastCompletedStatus(), Program); + break; + } + case "OBS_WEBSOCKET_OUTPUT_PAUSED": + { + Program.steamNotifications?.SendNotification("Recording paused", 1000, MessageType.INFO); + + IsPaused = true; + + _logger.LogInfo("Recording paused."); + break; + } + case "OBS_WEBSOCKET_OUTPUT_RESUMED": + { + Program.steamNotifications?.SendNotification("Recording resumed", 1000, MessageType.INFO); + + IsPaused = false; + + _logger.LogInfo("Recording resumed."); + break; + } + } + + break; + } + } + break; + } + } + } + + private void Reconnected(ReconnectionInfo msg) + { + LastWarning = ConnectionTypeWarning.Connected; + } + + private void Disconnected(DisconnectionInfo msg) + { + Program.steamNotifications?.SendNotification("Disconnected from OBS", 1000, MessageType.ERROR); + + try + { + _ = Task.Delay(2000).ContinueWith(_ => + { + _ = socket.Start(); + }); + + if (AttemptedToIdentify) + { + _logger.LogWarn("Failed to identify with websocket. Your password might be incorrect, retrying in 2 seconds.."); + return; + } + + Process[] processCollection = Process.GetProcesses(); + + if (!processCollection.Any(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32"))) + { + if (LastWarning != ConnectionTypeWarning.NoProcess) + { + _logger.LogWarn($"Couldn't find an OBS process, is your OBS running? ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.NoProcess; + } + else + { + bool FoundWebSocketDll = false; + + string OBSInstallationDirectory = processCollection.First(x => x.ProcessName.ToLower().StartsWith("obs64") || x.ProcessName.ToLower().StartsWith("obs32")).MainModule.FileName; + OBSInstallationDirectory = OBSInstallationDirectory.Remove(OBSInstallationDirectory.LastIndexOf("\\bin"), OBSInstallationDirectory.Length - OBSInstallationDirectory.LastIndexOf("\\bin")); + + if (Directory.GetDirectories(OBSInstallationDirectory).Any(x => x.ToLower().EndsWith("obs-plugins"))) + { + foreach (var b in Directory.GetDirectories($"{OBSInstallationDirectory}\\obs-plugins")) + { + if (Directory.GetFiles(b).Any(x => x.Contains("obs-websocket") && x.EndsWith(".dll"))) + { + FoundWebSocketDll = true; + break; + } + } + } + + if (FoundWebSocketDll) + { + if (LastWarning != ConnectionTypeWarning.ModInstalled) + { + _logger.LogFatal($"OBS seems to be running but the obs-websocket server isn't running. Please make sure you have the obs-websocket server activated! (Tools -> WebSocket Server Settings) ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.ModInstalled; + } + else + { + if (LastWarning != ConnectionTypeWarning.ModNotInstalled) + { + _logger.LogFatal($"OBS seems to be running but the obs-websocket server isn't installed. Please make sure you have the obs-websocket server installed! (To install, follow this link: https://bit.ly/3BCXfeS) ({msg.Type})"); + } + LastWarning = ConnectionTypeWarning.ModNotInstalled; + } + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to check if obs-websocket is installed: (Disconnect Reason: {msg.Type}) {ex}"); + } + } + + internal override async Task StartRecording() + { + if (!Program.LoadedConfig.AutomaticRecording) + return; + + if (!socket.IsRunning) + throw new ArgumentException("Connection with OBS is not established."); + + if (IsRecording) + { + await StopRecording(true); + + while (IsRecording) + { + Thread.Sleep(20); + } + } + + if (Program.LoadedConfig.MininumWaitUntilRecordingCanStart < 200 || Program.LoadedConfig.MininumWaitUntilRecordingCanStart > 2000) + { + _logger.LogWarn("MininumWaitUntilRecordingCanStart was reset to 800. Allowed range for value is between 200 and 2000"); + Program.LoadedConfig.MininumWaitUntilRecordingCanStart = 800; + } + + Thread.Sleep(Program.LoadedConfig.MininumWaitUntilRecordingCanStart); + socket.Send(new StartRecord().Build()); + } + + internal override async Task StopRecording(bool ForceStop = false) + { + if (!Program.LoadedConfig.AutomaticRecording) + return; + + if (!socket.IsRunning) + throw new ArgumentException("Connection with OBS is not established."); + + if (!ForceStop) + { + if (Program.LoadedConfig.StopRecordingDelay < 0 || Program.LoadedConfig.StopRecordingDelay > 20) + { + _logger.LogWarn("StopRecordingDelay was reset to 5. Allowed range for value is between 0 and 20"); + Program.LoadedConfig.StopRecordingDelay = 5; + } + + try + { + var millisecondsDelay = Program.LoadedConfig.StopRecordingDelay; + await Task.Delay((millisecondsDelay <= 0 ? 1 : millisecondsDelay) * 1000, this.StopRecordingDelayCancel.Token); + } + catch (OperationCanceledException) + { + return; + } + } + else + { + StopRecordingDelayCancel.Cancel(); + StopRecordingDelayCancel = new(); + } + + socket.Send(new StopRecord().Build()); + } + + internal override void PauseRecording() + { + socket.Send(new PauseRecord().Build()); + } + + internal override void ResumeRecording() + { + socket.Send(new ResumeRecord().Build()); + } + + internal override void SetCurrentScene(string scene) + { + socket.Send(new SetCurrentProgramScene(scene).Build()); + } + + internal override bool GetIsRunning() => socket?.IsRunning ?? false; + + internal override bool GetIsRecording() => IsRecording; + + internal override bool GetIsPaused() => IsPaused; + + internal override int GetRecordingSeconds() => RecordingSeconds; +} diff --git a/BeatRecorder/Util/OpenVR/EasyOpenVR/EasyOpenVRSingleton.cs b/BeatRecorder/Util/OpenVR/EasyOpenVR/EasyOpenVRSingleton.cs new file mode 100644 index 0000000..7fc9e56 --- /dev/null +++ b/BeatRecorder/Util/OpenVR/EasyOpenVR/EasyOpenVRSingleton.cs @@ -0,0 +1,1741 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Valve.VR; + +namespace BOLL7708 +{ + public sealed class EasyOpenVRSingleton + { + /** + * This is a singleton because in my own experience connecting multiple + * times to OpenVR from the same application is a terrible idea. + */ + private static EasyOpenVRSingleton __instance = null; + private EasyOpenVRSingleton() { } + + private bool _debug = true; + private Random _rnd = new(); + private EVRApplicationType _appType = EVRApplicationType.VRApplication_Background; + private Action _debugLogAction = null; + + public static EasyOpenVRSingleton Instance + { + get + { + __instance ??= new EasyOpenVRSingleton(); + return __instance; + } + } + #region setup + public void SetApplicationType(EVRApplicationType appType) + { + _appType = appType; + } + + /** + * Will output debug information + */ + public void SetDebug(bool debug) + { + _debug = debug; + } + + public void SetDebugLogAction(Action action) + { + _debugLogAction = action; + } + #endregion + + #region init + private uint _initState = 0; + public bool Init() + { + EVRInitError error = EVRInitError.Unknown; + try { + _initState = OpenVR.InitInternal(ref error, _appType); + } + catch (Exception e) + { + DebugLog(e, "You might be building for 32bit with a 64bit .dll, error"); + } + DebugLog(error); + return error == EVRInitError.None && _initState > 0; + } + public bool IsInitialized() + { + return _initState > 0; + } + #endregion + + #region statistics + public Compositor_CumulativeStats GetCumulativeStats() + { + Compositor_CumulativeStats stats = new(); + OpenVR.Compositor.GetCumulativeStats(ref stats, (uint)Marshal.SizeOf(stats)); + return stats; + } + + public Compositor_FrameTiming GetFrameTiming() + { + Compositor_FrameTiming timing = new(); + timing.m_nSize = (uint)Marshal.SizeOf(timing); + var success = OpenVR.Compositor.GetFrameTiming(ref timing, 0); + if (!success) DebugLog("Could not get frame timing."); + return timing; + } + + public Compositor_FrameTiming[] GetFrameTimings(uint count) + { + Compositor_FrameTiming[] timings = new Compositor_FrameTiming[count]; + var resultCount = OpenVR.Compositor.GetFrameTimings(timings); + if (resultCount == 0) DebugLog("Could not get frame timings."); + return timings; + } + #endregion + + #region tracking + public TrackedDevicePose_t[] GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin origin = ETrackingUniverseOrigin.TrackingUniverseStanding) + { + TrackedDevicePose_t[] trackedDevicePoses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount]; + OpenVR.System.GetDeviceToAbsoluteTrackingPose(origin, 0.0f, trackedDevicePoses); + return trackedDevicePoses; + } + #endregion + + #region chaperone + public HmdQuad_t GetPlayAreaRect() + { + HmdQuad_t rect = new(); + var success = OpenVR.Chaperone.GetPlayAreaRect(ref rect); + if (!success) DebugLog("Failure getting PlayAreaRect"); + return rect; + } + + public HmdVector2_t GetPlayAreaSize() + { + var size = new HmdVector2_t(); + var success = OpenVR.Chaperone.GetPlayAreaSize(ref size.v0, ref size.v1); + if (!success) DebugLog("Failure getting PlayAreaSize"); + return size; + } + + public bool MoveUniverse(HmdVector3_t offset, bool moveChaperone = true, bool moveLiveZeroPose = true) + { + OpenVR.ChaperoneSetup.RevertWorkingCopy(); // Sets working copy to current live settings + if (moveLiveZeroPose) MoveLiveZeroPose(offset); + if (moveChaperone) MoveChaperoneBounds(Utils.InvertVector(offset)); + var success = OpenVR.ChaperoneSetup.CommitWorkingCopy(EChaperoneConfigFile.Live); // Apply changes to live settings + if (!success) DebugLog("Failure to commit Chaperone changes."); + return success; + } + + public bool MoveChaperoneBounds(HmdVector3_t offset) + { + var success = OpenVR.ChaperoneSetup.GetWorkingCollisionBoundsInfo(out HmdQuad_t[] physQuad); + if (!success) DebugLog("Failure to load Chaperone bounds."); + + for (int i = 0; i < physQuad.Length; i++) + { + MoveCorner(ref physQuad[i].vCorners0); + MoveCorner(ref physQuad[i].vCorners1); + MoveCorner(ref physQuad[i].vCorners2); + MoveCorner(ref physQuad[i].vCorners3); + } + OpenVR.ChaperoneSetup.SetWorkingCollisionBoundsInfo(physQuad); + + void MoveCorner(ref HmdVector3_t corner) + { + // Will not change points at vertical 0, that's the bottom of the Chaperone. + // This at it appears the bottom gets reset to 0 at a regular interval anyway. + corner.v0 += offset.v0; + if (corner.v1 != 0) corner.v1 += offset.v1; + corner.v2 += offset.v2; + } + return success; + } + + public void MoveLiveZeroPose(HmdVector3_t offset) + { + var standingPos = new HmdMatrix34_t(); + var sittingPos = new HmdMatrix34_t(); + + OpenVR.ChaperoneSetup.GetWorkingStandingZeroPoseToRawTrackingPose(ref standingPos); + OpenVR.ChaperoneSetup.GetWorkingSeatedZeroPoseToRawTrackingPose(ref sittingPos); + + // As the zero pose is relative to the unvierse calibration and not the play area + // we need to adjust the offset with the rotation of the universe. + offset = Utils.MultiplyVectorWithRotationMatrix(offset, standingPos); + standingPos.m3 += offset.v0; + standingPos.m7 += offset.v1; + standingPos.m11 += offset.v2; + sittingPos.m3 += offset.v0; + sittingPos.m7 += offset.v1; + sittingPos.m11 += offset.v2; + + OpenVR.ChaperoneSetup.SetWorkingStandingZeroPoseToRawTrackingPose(ref standingPos); + OpenVR.ChaperoneSetup.SetWorkingSeatedZeroPoseToRawTrackingPose(ref sittingPos); + } + #endregion + + #region controller + /* + * Includes things like analogue axes of triggers, pads & sticks + * OBS: Deprecated + */ + public VRControllerState_t GetControllerState(uint index) + { + VRControllerState_t state = new(); + var success = OpenVR.System.GetControllerState(index, ref state, (uint)Marshal.SizeOf(state)); + if (!success) DebugLog("Failure getting ControllerState"); + return state; + } + + /** + * Will return the index of the role if found + * Useful if you want to know which controller is right or left. + * Note: Will eventually be removed as it has now been deprecated. + */ + public uint GetIndexForControllerRole(ETrackedControllerRole role) + { + return OpenVR.System.GetTrackedDeviceIndexForControllerRole(role); + } + #endregion + + #region tracked_device + public uint[] GetIndexesForTrackedDeviceClass(ETrackedDeviceClass _class) + { + // Not sure how this one works, no ref? Skip for now. + // var result = new uint[OpenVR.k_unMaxTrackedDeviceCount]; + // var count = OpenVR.System.GetSortedTrackedDeviceIndicesOfClass(_class, result, uint.MaxValue); + var result = new List(); + for (uint i = 0; i < OpenVR.k_unMaxTrackedDeviceCount; i++) + { + if (GetTrackedDeviceClass(i) == _class) result.Add(i); + } + return result.ToArray(); + } + + public ETrackedDeviceClass GetTrackedDeviceClass(uint index) + { + return OpenVR.System.GetTrackedDeviceClass(index); + } + + /* + * Example of property: ETrackedDeviceProperty.Prop_DeviceBatteryPercentage_Float + */ + public float GetFloatTrackedDeviceProperty(uint index, ETrackedDeviceProperty property) + { + var error = new ETrackedPropertyError(); + var result = OpenVR.System.GetFloatTrackedDeviceProperty(index, property, ref error); + DebugLog(error, property); + return result; + } + /* + * Example of property: ETrackedDeviceProperty.Prop_SerialNumber_String + */ + public string GetStringTrackedDeviceProperty(uint index, ETrackedDeviceProperty property) + { + var error = new ETrackedPropertyError(); + StringBuilder sb = new((int)OpenVR.k_unMaxPropertyStringSize); + OpenVR.System.GetStringTrackedDeviceProperty(index, property, sb, OpenVR.k_unMaxPropertyStringSize, ref error); + DebugLog(error); + return sb.ToString(); + } + + + /* + * Example of property: ETrackedDeviceProperty.Prop_EdidProductID_Int32 + */ + public int GetIntegerTrackedDeviceProperty(uint index, ETrackedDeviceProperty property) + { + var error = new ETrackedPropertyError(); + var result = OpenVR.System.GetInt32TrackedDeviceProperty(index, property, ref error); + DebugLog(error); + return result; + } + + /* + * Example of property: ETrackedDeviceProperty.Prop_CurrentUniverseId_Uint64 + */ + public ulong GetLongTrackedDeviceProperty(uint index, ETrackedDeviceProperty property) + { + var error = new ETrackedPropertyError(); + var result = OpenVR.System.GetUint64TrackedDeviceProperty(index, property, ref error); + DebugLog(error); + return result; + } + + /* + * Example of property: ETrackedDeviceProperty.Prop_ContainsProximitySensor_Bool + */ + public bool GetBooleanTrackedDeviceProperty(uint index, ETrackedDeviceProperty property) + { + var error = new ETrackedPropertyError(); + var result = OpenVR.System.GetBoolTrackedDeviceProperty(index, property, ref error); + DebugLog(error); + return result; + } + + // TODO: This has apparently been deprecated, figure out how to do it with the new input system. + public void TriggerHapticPulseInController(ETrackedControllerRole role) + { + var index = GetIndexForControllerRole(role); + OpenVR.System.TriggerHapticPulse(index, 0, 10000); // This works: https://github.com/ValveSoftware/openvr/wiki/IVRSystem::TriggerHapticPulse + } + + public InputOriginInfo_t GetOriginTrackedDeviceInfo(ulong originHandle) { + var info = new InputOriginInfo_t(); + var error = OpenVR.Input.GetOriginTrackedDeviceInfo(originHandle, ref info, (uint)Marshal.SizeOf(info)); + DebugLog(error); + return info; + } + #endregion + + #region events + private Dictionary>> _events = new(); + + ///Register an event that should trigger an action, run UpdateEvents() to get new events. + public void RegisterEvent(EVREventType type, Action action) + { + RegisterEvents(new EVREventType[1] { type }, action); + } + /** + * Register multiple events that will trigger the same action. + */ + public void RegisterEvents(EVREventType[] types, Action action) + { + foreach (var t in types) + { + if (!_events.ContainsKey(t)) _events.Add(t, new List>()); + _events[t].Add(action); + } + } + + /// Load new events and match them against registered events types, trigger actions. + public void UpdateEvents(bool debugUnhandledEvents = false) + { + var events = GetNewEvents(); + foreach (var e in events) + { + var type = (EVREventType)e.eventType; + if (_events.ContainsKey(type)) + { + foreach (var action in _events[type]) action.Invoke(e); + } + else if (debugUnhandledEvents) DebugLog((EVREventType)e.eventType, "Unhandled event"); + } + } + + ///Will get all new events in the queue, note that this will cancel out triggering any registered events when running UpdateEvents(). + public VREvent_t[] GetNewEvents() + { + var vrEvents = new List(); + var vrEvent = new VREvent_t(); + uint eventSize = (uint)Marshal.SizeOf(vrEvent); + try + { + while (OpenVR.System.PollNextEvent(ref vrEvent, eventSize)) + { + vrEvents.Add(vrEvent); + } + } catch (Exception e) + { + DebugLog(e, "Could not get new events"); + } + + return vrEvents.ToArray(); + } + #endregion + + #region input + + /** + * From the SteamVR Unity Plugin: https://github.com/ValveSoftware/steamvr_unity_plugin/blob/master/Assets/SteamVR/Input/SteamVR_Input_Sources.cs + * Used to get the handle for any specific input source. + */ + public enum InputSource + { + [Description("/unrestricted")] + Any, + + [Description(OpenVR.k_pchPathUserHandLeft)] + LeftHand, + [Description(OpenVR.k_pchPathUserElbowLeft)] + LeftElbow, + [Description(OpenVR.k_pchPathUserShoulderLeft)] + LeftShoulder, + [Description(OpenVR.k_pchPathUserKneeLeft)] + LeftKnee, + [Description(OpenVR.k_pchPathUserFootLeft)] + LeftFoot, + + [Description(OpenVR.k_pchPathUserHandRight)] + RightHand, + [Description(OpenVR.k_pchPathUserElbowRight)] + RightElbow, + [Description(OpenVR.k_pchPathUserShoulderRight)] + RightShoulder, + [Description(OpenVR.k_pchPathUserKneeRight)] + RightKnee, + [Description(OpenVR.k_pchPathUserFootRight)] + RightFoot, + + [Description(OpenVR.k_pchPathUserHead)] + Head, + [Description(OpenVR.k_pchPathUserChest)] + Chest, + [Description(OpenVR.k_pchPathUserWaist)] + Waist, + + [Description(OpenVR.k_pchPathUserGamepad)] + Gamepad, + [Description(OpenVR.k_pchPathUserStylus)] + Stylus, + [Description(OpenVR.k_pchPathUserKeyboard)] + Keyboard, + + [Description(OpenVR.k_pchPathUserCamera)] + Camera, + [Description(OpenVR.k_pchPathUserTreadmill)] + Treadmill, + } + + public enum InputType + { + Analog, + Digital, + Pose + } + private class InputAction + { + internal string path; + internal object data; + internal InputType type; + internal object action; + internal ulong handle = 0; + internal string pathEnd = ""; + internal bool isChord = false; // Needed to avoid filtering on the input source handle as Chords can flip their on/off action between sources depending on which button is activated/deactivated first. + + internal InputActionInfo getInfo(ulong sourceHandle) + { + return new InputActionInfo + { + handle = handle, + path = path, + pathEnd = pathEnd, + sourceHandle = sourceHandle + }; + } + } + + public class InputActionInfo + { + public ulong handle; + public string path; + public string pathEnd; + public ulong sourceHandle; + } + + private List _inputActions = new(); + private List _inputActionSets = new(); + + /** + * Load the actions manifest to register actions for the application + * OBS: Make sure the encoding is UTF8 and not UTF8+BOM + */ + public EVRInputError LoadActionManifest(string relativePath) + { + return OpenVR.Input.SetActionManifestPath(Path.GetFullPath(relativePath)); + } + + public bool RegisterActionSet(string path) + { + ulong handle = 0; + var error = OpenVR.Input.GetActionSetHandle(path, ref handle); + if (handle != 0 && error == EVRInputError.None) + { + var actionSet = new VRActiveActionSet_t + { + ulActionSet = handle, + ulRestrictedToDevice = OpenVR.k_ulInvalidActionSetHandle, + nPriority = 0 + }; + _inputActionSets.Add(actionSet); + } + return DebugLog(error); + } + + private EVRInputError RegisterAction(ref InputAction ia) + { + ulong handle = 0; + var error = OpenVR.Input.GetActionHandle(ia.path, ref handle); + var pathParts = ia.path.Split('/'); + if (handle != 0 && error == EVRInputError.None) + { + ia.handle = handle; + ia.pathEnd = pathParts[^1]; + _inputActions.Add(ia); + } + else DebugLog(error); + return error; + } + + public void ClearInputActions() + { + _inputActionSets.Clear(); + _inputActions.Clear(); + } + + /** + * Register an analog action with a callback action + */ + public bool RegisterAnalogAction(string path, Action action, bool isChord = false) + { + var ia = new InputAction + { + path = path, + type = InputType.Analog, + action = action, + data = new InputAnalogActionData_t(), + isChord = isChord + }; + var error = RegisterAction(ref ia); + return DebugLog(error); + } + + /** + * Register a digital action with a callback action + */ + public bool RegisterDigitalAction(string path, Action action, bool isChord = false) + { + var inputAction = new InputAction + { + path = path, + type = InputType.Digital, + action = action, + data = new InputDigitalActionData_t(), + isChord = isChord + }; + var error = RegisterAction(ref inputAction); + return DebugLog(error); + } + + /** + * Register a digital action with a callback action + */ + public bool RegisterPoseAction(string path, Action action, bool isChord = false) + { + var inputAction = new InputAction + { + path = path, + type = InputType.Pose, + action = action, + data = new InputPoseActionData_t(), + isChord = isChord + }; + var error = RegisterAction(ref inputAction); + return DebugLog(error); + } + + /** + * Retrieve the handle for the input source of a specific input device + */ + public ulong GetInputSourceHandle(InputSource inputSource) + { + + DescriptionAttribute[] attributes = (DescriptionAttribute[])inputSource + .GetType() + .GetField(inputSource.ToString()) + .GetCustomAttributes(typeof(DescriptionAttribute), false); + var source = attributes.Length > 0 ? attributes[0].Description : string.Empty; + + ulong handle = 0; + var error = OpenVR.Input.GetInputSourceHandle(source, ref handle); + DebugLog(error); + return handle; + } + + + /** + * Update all action states, this will trigger stored actions if needed. + * Digital actions triggers on change, analog actions every update. + * OBS: Only run this once per update, or you'll get no input data at all. + */ + public bool UpdateActionStates(ulong[] inputSourceHandles = null) + { + inputSourceHandles ??= new ulong[] { OpenVR.k_ulInvalidPathHandle }; + var error = OpenVR.Input.UpdateActionState(_inputActionSets.ToArray(), (uint)Marshal.SizeOf(typeof(VRActiveActionSet_t))); + + _inputActions.ForEach((InputAction action) => + { + switch (action.type) + { + case InputType.Analog: + foreach (var handle in inputSourceHandles) GetAnalogAction(action, handle); + break; + case InputType.Digital: + foreach (var handle in inputSourceHandles) GetDigitalAction(action, handle); + break; + case InputType.Pose: + foreach (var handle in inputSourceHandles) GetPoseAction(action, handle); + break; + } + }); + return DebugLog(error); + } + + private bool GetAnalogAction(InputAction inputAction, ulong inputSourceHandle) + { + if (inputAction.isChord) inputSourceHandle = 0; + var size = (uint)Marshal.SizeOf(typeof(InputAnalogActionData_t)); + var data = (InputAnalogActionData_t)inputAction.data; + var error = OpenVR.Input.GetAnalogActionData(inputAction.handle, ref data, size, inputSourceHandle); + var action = ((Action)inputAction.action); + if(data.bActive) action.Invoke(data, inputAction.getInfo(inputSourceHandle)); + return DebugLog(error, $"handle: {inputAction.handle}, error"); + } + + private bool GetDigitalAction(InputAction inputAction, ulong inputSourceHandle) + { + if (inputAction.isChord) inputSourceHandle = 0; + var size = (uint)Marshal.SizeOf(typeof(InputDigitalActionData_t)); + var data = (InputDigitalActionData_t)inputAction.data; + var error = OpenVR.Input.GetDigitalActionData(inputAction.handle, ref data, size, inputSourceHandle); + var action = ((Action)inputAction.action); + if (data.bActive && data.bChanged) action.Invoke(data, inputAction.getInfo(inputSourceHandle)); + return DebugLog(error, $"handle: {inputAction.handle}, error"); + } + + private bool GetPoseAction(InputAction inputAction, ulong inputSourceHandle) + { + if (inputAction.isChord) inputSourceHandle = 0; + var size = (uint)Marshal.SizeOf(typeof(InputPoseActionData_t)); + var data = (InputPoseActionData_t)inputAction.data; + var error = OpenVR.Input.GetPoseActionDataRelativeToNow(inputAction.handle, ETrackingUniverseOrigin.TrackingUniverseStanding, 0f, ref data, size, inputSourceHandle); + var action = ((Action)inputAction.action); + if (data.bActive) action.Invoke(data, inputAction.getInfo(inputSourceHandle)); + return DebugLog(error, $"handle: {inputAction.handle}, error"); + } + #endregion + + #region screenshots + public class ScreenshotResult + { + public uint handle; + public EVRScreenshotType type; + public string filePath; + public string filePathVR; + } + + /* + * Set screenshot path, if not set they will end up in: %programfiles(x86)%\Steam\steamapps\common\SteamVR\bin\ + * Returns false if the directory does not exist. + */ + private string _screenshotPath = ""; + public bool SetScreenshotOutputFolder(string path) + { + var exists = Directory.Exists(path); + if (exists) _screenshotPath = path; + return exists; + } + + /* + * Hooks the screenshot function so it overrides the built in screenshot shortcut in SteamVR! + * Listen to the VREvent_ScreenshotTriggered event to know when to acquire a screenshot. + */ + public bool HookScreenshots() + { + EVRScreenshotType[] arr = { EVRScreenshotType.Stereo }; + var error = OpenVR.Screenshots.HookScreenshot(arr); + return DebugLog(error); + } + + private Tuple GetScreenshotPaths(string prefix, string postfix, string timestampFormat = "yyyyMMdd_HHmmss_fff") + { + var screenshotPath = _screenshotPath; + if (screenshotPath != string.Empty) screenshotPath = $"{screenshotPath}\\"; + if (prefix != string.Empty) prefix = $"{prefix}_"; + if (postfix != string.Empty) postfix = $"_{postfix}"; + var timestamp = DateTime.Now.ToString(timestampFormat); + + var filePath = $"{screenshotPath}{prefix}{timestamp}{postfix}"; + var filePathVR = $"{screenshotPath}{prefix}{timestamp}_vr{postfix}"; + + return new Tuple(filePath, filePathVR); + } + + /** + * Takes a stereo screenshot, works with all applications as it grabs render output directly. + * + * OBS: Requires a scene application to be running, else screenshot functionality will stop working. + */ + public bool TakeScreenshot( + out ScreenshotResult screenshotResult, + string prefix = "", + string postfix = "") + { + uint handle = 0; + var filePaths = GetScreenshotPaths(prefix, postfix); + var type = EVRScreenshotType.Stereo; + var error = OpenVR.Screenshots.TakeStereoScreenshot(ref handle, filePaths.Item1, filePaths.Item2); + screenshotResult = + error == EVRScreenshotError.None ? + new ScreenshotResult { + handle = handle, + type = type, + filePath = filePaths.Item1, + filePathVR = filePaths.Item2 + } : null; + return DebugLog(error); + } + + /** + * Use this to request other types of screenshots. + * + * OBS: This will NOT WORK if you have hooked the system screenshot function, + * it will seemingly leave a screenshot request in limbo preventing future screenshots. + */ + public bool RequestScreenshot( + out ScreenshotResult screenshotResult, + string prefix = "", + string postfix = "", + EVRScreenshotType screenshotType = EVRScreenshotType.Stereo) + { + var filePaths = GetScreenshotPaths(prefix, postfix); + uint handle = 0; + var error = OpenVR.Screenshots.RequestScreenshot(ref handle, screenshotType, filePaths.Item1, filePaths.Item2); + screenshotResult = + error == EVRScreenshotError.None ? + new ScreenshotResult { + handle = handle, + type = screenshotType, + filePath = filePaths.Item1, + filePathVR = filePaths.Item2 + } : null; + return DebugLog(error); + } + + /* + * This will attempt to submit the screenshot to Steam to be in the screenshot library for the current scene application. + */ + public bool SubmitScreenshotToSteam(ScreenshotResult screenshotResult) + { + var error = OpenVR.Screenshots.SubmitScreenshot( + screenshotResult.handle, + screenshotResult.type, + $"{screenshotResult.filePath}.png", + $"{screenshotResult.filePathVR}.png" + ); + return DebugLog(error); + } + + #endregion + + #region video + public float GetRenderTargetForCurrentApp() + { + return GetFloatSetting(OpenVR.k_pch_SteamVR_Section, OpenVR.k_pch_SteamVR_SupersampleScale_Float); + } + + public bool GetSuperSamplingEnabledForCurrentApp() + { + return GetBoolSetting(OpenVR.k_pch_SteamVR_Section, OpenVR.k_pch_SteamVR_SupersampleManualOverride_Bool); + } + + public bool SetSuperSamplingEnabledForCurrentApp(bool enabled) + { + return SetBoolSetting(OpenVR.k_pch_SteamVR_Section, OpenVR.k_pch_SteamVR_SupersampleManualOverride_Bool, enabled); + } + + public float GetSuperSamplingForCurrentApp() + { + return GetFloatSetting(OpenVR.k_pch_SteamVR_Section, OpenVR.k_pch_SteamVR_SupersampleScale_Float); + } + + /** + * Will set the render scale for the current app + * scale 1 = 100% + * OBS: Will enable super sampling override if it is not enabled + */ + public bool SetSuperSamplingForCurrentApp(float scale) + { + return SetFloatSetting(OpenVR.k_pch_SteamVR_Section, OpenVR.k_pch_SteamVR_SupersampleScale_Float, scale); + } + #endregion + + #region notifications + /* + * Thank you artumino and in extension Marlamin on GitHub for their public code which I referenced for notifications. + * Also thanks to Valve for finally adding the interface for notifications to the C# header file. + * + * In reality I tried implementing notifications back in April 2016, poked Valve about it in October the same year, + * pointed out what was missing in May and December 2017, yet again in January 2019 and boom, now we have it! + */ + + private List _notifications = new(); + + /* + * We initialize an overlay to display notifications with. + * The title will be visible above the notification. + * Returns the handle used to send notifications, 0 on fail. + */ + public ulong InitNotificationOverlay(string notificationTitle) + { + ulong handle = 0; + var key = Guid.NewGuid().ToString(); + var error = OpenVR.Overlay.CreateOverlay(key, notificationTitle, ref handle); + if (DebugLog(error)) return handle; + return 0; + } + + public uint EnqueueNotification(ulong overlayHandle, string message) + { + return EnqueueNotification(overlayHandle, message, new NotificationBitmap_t()); + } + + public uint EnqueueNotification(ulong overlayHandle, string message, NotificationBitmap_t bitmap) + { + return EnqueueNotification(overlayHandle, EVRNotificationType.Transient, message, EVRNotificationStyle.Application, bitmap); + } + + /* + * Will enqueue a notification to be displayed in the headset. + * Returns ID for this specific notification. + */ + public uint EnqueueNotification(ulong overlayHandle, EVRNotificationType type, string message, EVRNotificationStyle style, NotificationBitmap_t bitmap) + { + uint id = 0; + while (id == 0 || _notifications.Contains(id)) id = (uint)_rnd.Next(); // Not sure why we do this + var error = OpenVR.Notifications.CreateNotification(overlayHandle, 0, type, message, style, ref bitmap, ref id); + DebugLog(error); + _notifications.Add(id); + return id; + } + + /* + * Used to dismiss a persistent notification. + */ + public bool DismissNotification(uint id, out EVRNotificationError error) + { + error = OpenVR.Notifications.RemoveNotification(id); + if (error == EVRNotificationError.OK) _notifications.Remove(id); + return DebugLog(error); + } + + public bool EmptyNotificationsQueue() + { + var success = true; + foreach (uint id in _notifications) + { + EVRNotificationError error = OpenVR.Notifications.RemoveNotification(id); + success = DebugLog(error); + } + _notifications.Clear(); + return success; + } + #endregion + + #region Settings + /// + /// Fetches a settings value from the SteamVR settings + /// + /// Example: OpenVR.k_pch_CollisionBounds_Section + /// Example: OpenVR.k_pch_SteamVR_SupersampleScale_Float + /// float value + public float GetFloatSetting(string section, string setting) { + EVRSettingsError error = EVRSettingsError.None; + var value = OpenVR.Settings.GetFloat( + section, + setting, + ref error + ); + DebugLog(error); + return value; + } + + public bool SetFloatSetting(string section, string setting, float value) { + EVRSettingsError error = EVRSettingsError.None; + OpenVR.Settings.SetFloat(section, setting, value, ref error); + return DebugLog(error); + } + + public bool GetBoolSetting(string section, string setting) { + EVRSettingsError error = EVRSettingsError.None; + var value = OpenVR.Settings.GetBool( + section, + setting, + ref error + ); + DebugLog(error); + return value; + } + + public bool SetBoolSetting(string section, string setting, bool value) { + EVRSettingsError error = EVRSettingsError.None; + OpenVR.Settings.SetBool(section, setting, value, ref error); + return DebugLog(error); + } + + public int GetIntSetting(string section, string setting) + { + EVRSettingsError error = EVRSettingsError.None; + var value = OpenVR.Settings.GetInt32( + section, + setting, + ref error + ); + DebugLog(error); + return value; + } + + public bool SetIntSetting(string section, string setting, int value) + { + EVRSettingsError error = EVRSettingsError.None; + OpenVR.Settings.SetInt32(section, setting, value, ref error); + return DebugLog(error); + } + + public string GetStringSetting(string section, string setting) + { + /* TODO: Reference Unity plugin? + EVRSettingsError error = EVRSettingsError.None; + var sb = new StringBuilder(); + OpenVR.Settings.GetString( + section, + setting, + sb, + + ref error + ); + DebugLog(error); + return value; + */ + return ""; + } + + public bool SetStringSetting(string section, string setting, string value) + { + EVRSettingsError error = EVRSettingsError.None; + OpenVR.Settings.SetString(section, setting, value, ref error); + return DebugLog(error); + } + + #endregion + + #region overlays + /// + /// Creates an overlay that will show up in the headset if you draw to it + /// + /// + /// + /// Get an empty transform from Utils.GetEmptyTransform + /// Default is 1, height is derived from the texture aspect ratio and the width + /// Default is none, else index for which tracked device to attach overlay to + /// If we have no anchor, we need an origin to set position, defaults to standing + /// 0 if we failed to create an overlay + public ulong CreateOverlay(string uniqueKey, string title, HmdMatrix34_t transform, float width = 1, uint anchor=uint.MaxValue, ETrackingUniverseOrigin origin = ETrackingUniverseOrigin.TrackingUniverseStanding) + { + ulong handle = 0; + var error = OpenVR.Overlay.CreateOverlay(uniqueKey, title, ref handle); + if(error == EVROverlayError.None) + { + OpenVR.Overlay.SetOverlayWidthInMeters(handle, width); + if (anchor != uint.MaxValue) OpenVR.Overlay.SetOverlayTransformTrackedDeviceRelative(handle, anchor, ref transform); + else OpenVR.Overlay.SetOverlayTransformAbsolute(handle, origin, ref transform); + } + DebugLog(error); + return handle; + } + + public bool SetOverlayTransform(ulong handle, HmdMatrix34_t transform, uint anchor = uint.MaxValue, ETrackingUniverseOrigin origin = ETrackingUniverseOrigin.TrackingUniverseStanding) + { + EVROverlayError error; + if (anchor != uint.MaxValue) error = OpenVR.Overlay.SetOverlayTransformTrackedDeviceRelative(handle, anchor, ref transform); + else error = OpenVR.Overlay.SetOverlayTransformAbsolute(handle, origin, ref transform); + return DebugLog(error); + } + + public bool SetOverlayTextureFromFile(ulong handle, string path) + { + var error = OpenVR.Overlay.SetOverlayFromFile(handle, path); + return DebugLog(error); + } + + /// + /// Preliminiary as I have yet to figure out how to make my own textures at runtime. + /// + /// + /// + /// + public bool SetOverlayTexture(ulong handle, ref Texture_t texture) + { + // DXGI_FORMAT_R8G8B8A8_UNORM + var error = OpenVR.Overlay.SetOverlayTexture(handle, ref texture); + return DebugLog(error); + } + + /// + /// Sets raw overlay pixels from Bitmap, appears to crash íf going above 1mpix or near that. + /// It's also said to be super inefficient by Valve themselves, so never use this for frequently updating overlays. + /// + /// + /// + public void SetOverlayPixels(ulong handle, Bitmap bmp) + { + BitmapUtils.PointerFromBitmap(bmp, true, (pointer) => { + int bytesPerPixel = Bitmap.GetPixelFormatSize(bmp.PixelFormat) / 8; + var error = OpenVR.Overlay.SetOverlayRaw(handle, pointer, (uint) bmp.Width, (uint) bmp.Height, (uint) bytesPerPixel); + }); + } + + public HmdMatrix34_t GetOverlayTransform(ulong handle, ETrackingUniverseOrigin origin = ETrackingUniverseOrigin.TrackingUniverseStanding) + { + var transform = new HmdMatrix34_t(); + var error = OpenVR.Overlay.GetOverlayTransformAbsolute(handle, ref origin, ref transform); + DebugLog(error); + return transform; + } + + /// + /// Sets the alpha of the overlay + /// + /// + /// Normalized 0.0-1.0 + /// + public bool SetOverlayAlpha(ulong handle, float alpha) + { + var error = OpenVR.Overlay.SetOverlayAlpha(handle, alpha); + return DebugLog(error); + } + + public bool SetOverlayWidth(ulong handle, float width) + { + var error = OpenVR.Overlay.SetOverlayWidthInMeters(handle, width); + return DebugLog(error); + } + + public bool SetOverlayVisibility(ulong handle, bool visible) + { + EVROverlayError error; + if (visible) error = OpenVR.Overlay.ShowOverlay(handle); + else error = OpenVR.Overlay.HideOverlay(handle); + return DebugLog(error); + } + + /** + * Will have to explore this at a later date, right now my overlays are non-interactive. + */ + public VREvent_t[] GetNewOverlayEvents(ulong overlayHandle) + { + var vrEvents = new List(); + var vrEvent = new VREvent_t(); + uint eventSize = (uint)Marshal.SizeOf(vrEvent); + while (OpenVR.Overlay.PollNextOverlayEvent(overlayHandle, ref vrEvent, eventSize)) + { + vrEvents.Add(vrEvent); + } + return vrEvents.ToArray(); + } + public ulong FindOverlay(string uniqueKey) + { + ulong handle = 0; + var error = OpenVR.Overlay.FindOverlay(uniqueKey, ref handle); + DebugLog(error); + return handle; + } + + public class OverlayTextureSize + { + public uint width; + public uint height; + public float aspectRatio; + } + + public OverlayTextureSize GetOverlayTextureSize(ulong handle) + { + uint width = 0; + uint height = 0; + var error = OpenVR.Overlay.GetOverlayTextureSize(handle, ref width, ref height); + DebugLog(error); + return (width == 0 || height == 0) ? + new OverlayTextureSize() : + new OverlayTextureSize { width=width, height=height, aspectRatio=(float)width/(float)height }; + } + #endregion + + #region shutting down + + /* + * Listen for a VREvent_Quit and run this afterwards for your application to not get terminated. Then run Shutdown. + */ + public void AcknowledgeShutdown() + { + OpenVR.System.AcknowledgeQuit_Exiting(); + } + + /* + * Run this after AcknowledgeShutdown and after finishing all work, or OpenVR will likely throw an exception. + */ + public void Shutdown() + { + OpenVR.Shutdown(); + _initState = 0; + _events = new Dictionary>>(); + _inputActions = new List(); + } + + #endregion + + #region system + + /** + * Load an app manifest for the application + * Pretty sure this is required to show up in the input bindings interface + * OBS: Make sure the encoding is UTF8 and not UTF8+BOM + */ + public bool LoadAppManifest(string relativePath) + { + var error = OpenVR.Applications.AddApplicationManifest(Path.GetFullPath(relativePath), false); + return DebugLog(error); + } + + public bool RemoveAppManifest(string relativePath) + { + var error = OpenVR.Applications.RemoveApplicationManifest(Path.GetFullPath(relativePath)); + return DebugLog(error); + } + + /// + /// Will add the application manifest and optionally register for auto launch. + /// OBS: For auto launch to work the manifest must include "is_dashboard_overlay": true. + /// + /// The relative path to your application manifest + /// Application key, used to check if already installed. + /// Optional flag to register for auto launch. + /// + public bool AddApplicationManifest(string relativeManifestPath, string applicationKey, bool alsoRegisterAutoLaunch=false) { + if (!OpenVR.Applications.IsApplicationInstalled(applicationKey)) + { + var manifestError = OpenVR.Applications.AddApplicationManifest(Path.GetFullPath(relativeManifestPath), false); + if(manifestError == EVRApplicationError.None && alsoRegisterAutoLaunch) + { + var autolaunchError = OpenVR.Applications.SetApplicationAutoLaunch(applicationKey, true); + return DebugLog(autolaunchError); + } + return DebugLog(manifestError); + } + return false; + } + + /** + * Will return the application ID for the currently running scene application. + * Will return an empty string is there is no result. + */ + public string GetRunningApplicationId() + { + var pid = OpenVR.Applications.GetCurrentSceneProcessId(); + var sb = new StringBuilder((int)OpenVR.k_unMaxApplicationKeyLength); + var error = OpenVR.Applications.GetApplicationKeyByProcessId(pid, sb, OpenVR.k_unMaxApplicationKeyLength); + DebugLog(error); + return sb.ToString(); + } + + public string GetRuntimeVersion() + { + var version = "N/A"; + if (OpenVR.IsRuntimeInstalled()) + { + version = OpenVR.System.GetRuntimeVersion(); + } + return version; + } + #endregion + + #region private_utils + private void DebugLog(string message) + { + if (_debug) + { + var st = new StackTrace(); + var sf = st.GetFrame(1); + var methodName = sf.GetMethod().Name; + var text = $"{methodName}: {message}"; + _debugLogAction?.Invoke(text); + Debug.WriteLine(text); + } + } + private bool DebugLog(Enum errorEnum, string message = "error") + { + var errorVal = Convert.ChangeType(errorEnum, errorEnum.GetTypeCode()); + var ok = (int)errorVal == 0; + if (_debug && !ok) + { + var stackTrace = new StackTrace(); + var stackFrame = stackTrace.GetFrame(1); + var methodName = stackFrame.GetMethod().Name; + var text = $"{methodName} {message}: {Enum.GetName(errorEnum.GetType(), errorEnum)}"; + _debugLogAction?.Invoke(text); + Debug.WriteLine(text); + } + return ok; + } + + private bool DebugLog(Enum errorEnum, Enum valueEnum) + { + var errorVal = Convert.ChangeType(errorEnum, errorEnum.GetTypeCode()); + var ok = (int)errorVal == 0; + if (_debug && !ok) + { + var stackTrace = new StackTrace(); + var stackFrame = stackTrace.GetFrame(1); + var methodName = stackFrame.GetMethod().Name; + var text = $"{methodName} {Enum.GetName(valueEnum.GetType(), valueEnum)}: {Enum.GetName(errorEnum.GetType(), errorEnum)}"; + _debugLogAction?.Invoke(text); + Debug.WriteLine(text); + } + return ok; + } + + private void DebugLog(Exception e, string message = "error") + { + if (_debug) + { + var st = new StackTrace(); + var sf = st.GetFrame(1); + var methodName = sf.GetMethod().Name; + var text = $"{methodName} {message}: {e.Message}"; + _debugLogAction?.Invoke(text); + Debug.WriteLine(text); + } + } + #endregion + + #region utils + public class YPR + { + public double yaw; + public double pitch; + public double roll; + public YPR() { } + public YPR(double yaw, double pitch, double roll) + { + this.yaw = yaw; + this.pitch = pitch; + this.roll = roll; + } + public YPR(HmdVector3_t vec) { + pitch = vec.v0; + yaw = vec.v1; + roll = vec.v2; + } + } + + public static class Utils + { + public static HmdMatrix34_t GetEmptyTransform() + { + var transform = new HmdMatrix34_t(); + transform.m0 = 1; + transform.m5 = 1; + transform.m10 = 1; + return transform; + } + + public static HmdMatrix34_t GetTransformFromEuler(YPR e) + { + // Assuming the angles are in radians. + // Had to switch roll and pitch here to match SteamVR + var ch = (float) Math.Cos(e.yaw); + var sh = (float) Math.Sin(e.yaw); + var ca = (float) Math.Cos(e.roll); + var sa = (float) Math.Sin(e.roll); + var cb = (float) Math.Cos(e.pitch); + var sb = (float) Math.Sin(e.pitch); + + return new HmdMatrix34_t + { + m0 = ch * ca, + m1 = (sh * sb) - (ch * sa * cb), + m2 = (ch * sa * sb) + (sh * cb), + m4 = sa, + m5 = ca * cb, + m6 = -ca * sb, + m8 = -sh * ca, + m9 = (sh * sa * cb) + (ch * sb), + m10 = (-sh * sa * sb) + (ch * cb) + }; + } + + public static HmdVector3_t InvertVector(HmdVector3_t position) + { + position.v0 = -position.v0; + position.v1 = -position.v1; + position.v2 = -position.v2; + return position; + } + + public static HmdVector3_t MultiplyVectorWithRotationMatrix(HmdVector3_t v, HmdMatrix34_t m) + { + return new HmdVector3_t + { + v0 = (m.m0 * v.v0) + (m.m1 * v.v1) + (m.m2 * v.v2), + v1 = (m.m4 * v.v0) + (m.m5 * v.v1) + (m.m6 * v.v2), + v2 = (m.m8 * v.v0) + (m.m9 * v.v1) + (m.m10 * v.v2) + }; + } + + public static HmdMatrix34_t AddVectorToMatrix(HmdMatrix34_t m, HmdVector3_t v) { + var v2 = MultiplyVectorWithRotationMatrix(v, m); + m.m3 += v2.v0; + m.m7 += v2.v1; + m.m11 += v2.v2; + return m; + } + + public static HmdMatrix34_t MultiplyMatrixWithMatrix(HmdMatrix34_t matA, HmdMatrix34_t matB) + { + return new HmdMatrix34_t + { + // Row 0 + m0 = (matA.m0 * matB.m0) + (matA.m1 * matB.m4) + (matA.m2 * matB.m8), + m1 = (matA.m0 * matB.m1) + (matA.m1 * matB.m5) + (matA.m2 * matB.m9), + m2 = (matA.m0 * matB.m2) + (matA.m1 * matB.m6) + (matA.m2 * matB.m10), + m3 = (matA.m0 * matB.m3) + (matA.m1 * matB.m7) + (matA.m2 * matB.m11) + matA.m3, + + // Row 1 + m4 = (matA.m4 * matB.m0) + (matA.m5 * matB.m4) + (matA.m6 * matB.m8), + m5 = (matA.m4 * matB.m1) + (matA.m5 * matB.m5) + (matA.m6 * matB.m9), + m6 = (matA.m4 * matB.m2) + (matA.m5 * matB.m6) + (matA.m6 * matB.m10), + m7 = (matA.m4 * matB.m3) + (matA.m5 * matB.m7) + (matA.m6 * matB.m11) + matA.m7, + + // Row 2 + m8 = (matA.m8 * matB.m0) + (matA.m9 * matB.m4) + (matA.m10 * matB.m8), + m9 = (matA.m8 * matB.m1) + (matA.m9 * matB.m5) + (matA.m10 * matB.m9), + m10 = (matA.m8 * matB.m2) + (matA.m9 * matB.m6) + (matA.m10 * matB.m10), + m11 = (matA.m8 * matB.m3) + (matA.m9 * matB.m7) + (matA.m10 * matB.m11) + matA.m11, + }; + } + + // Dunno if you like having this here, but it helped me with debugging. + public static string MatToString(HmdMatrix34_t mat) + { + return $"[{mat.m0}, {mat.m1}, {mat.m2}, {mat.m3},\n" + + $"{mat.m4}, {mat.m5}, {mat.m6}, {mat.m7},\n" + + $"{mat.m8}, {mat.m9}, {mat.m10}, {mat.m11}]"; + } + + public static HmdQuaternion_t QuaternionFromMatrix(HmdMatrix34_t m) + { + var w = Math.Sqrt(1 + m.m0 + m.m5 + m.m10) / 2.0; + return new HmdQuaternion_t + { + w = w, // Scalar + x = (m.m9 - m.m6) / (4 * w), + y = (m.m2 - m.m8) / (4 * w), + z = (m.m4 - m.m1) / (4 * w) + }; + } + + public static YPR RotationMatrixToYPR(HmdMatrix34_t m) + { + // Had to switch roll and pitch here to match SteamVR + var q = QuaternionFromMatrix(m); + double test = (q.x * q.y) + (q.z * q.w); + if (test > 0.499) + { // singularity at north pole + return new YPR + { + yaw = 2 * Math.Atan2(q.x, q.w), // heading + roll = Math.PI / 2, // attitude + pitch = 0 // bank + }; + } + if (test < -0.499) + { // singularity at south pole + return new YPR + { + yaw = -2 * Math.Atan2(q.x, q.w), // headingq + roll = -Math.PI / 2, // attitude + pitch = 0 // bank + }; + } + double sqx = q.x * q.x; + double sqy = q.y * q.y; + double sqz = q.z * q.z; + return new YPR + { + yaw = Math.Atan2((2 * q.y * q.w) - (2 * q.x * q.z), 1 - (2 * sqy) - (2 * sqz)), // heading + roll = Math.Asin(2 * test), // attitude + pitch = Math.Atan2((2 * q.x * q.w) - (2 * q.y * q.z), 1 - (2 * sqx) - (2 * sqz)) // bank + }; + } + + #region Measurement + /// + /// Returns the angle between two matrices in degrees. + /// + /// + /// + /// + public static double AngleBetween(HmdMatrix34_t matOrigin, HmdMatrix34_t matTarget) + { + var vecOrigin = GetUnitVec3(); + var vecTarget = GetUnitVec3(); + vecOrigin = MultiplyVectorWithRotationMatrix(vecOrigin, matOrigin); + vecTarget = MultiplyVectorWithRotationMatrix(vecTarget, matTarget); + var vecSize = 1.0; + return Math.Acos(DotProduct(vecOrigin, vecTarget) / Math.Pow(vecSize, 2)) * (180/Math.PI); + } + + private static HmdVector3_t GetUnitVec3() { + return new HmdVector3_t() { v0 = 0, v1 = 0, v2 = 1 }; + } + + private static double DotProduct(HmdVector3_t v1, HmdVector3_t v2) + { + return (v1.v0 * v2.v0) + (v1.v1 * v2.v1) + (v1.v2 * v2.v2); + } + #endregion + } + + public static class BitmapUtils + { + /// + /// Generate the needed bitmap for SteamVR notifications + /// By default we flip red and blue image channels as that seems to always be required for it to display properly + /// + /// The system bitmap + /// Whether we should flip red and blue channels or not + /// + public static NotificationBitmap_t NotificationBitmapFromBitmap(Bitmap bmp, bool flipRnB=true) + { + return NotificationBitmapFromBitmapData(BitmapDataFromBitmap(bmp, flipRnB)); + } + + public static BitmapData BitmapDataFromBitmap(Bitmap bmpIn, bool flipRnB=false) + { + Bitmap bmp = (Bitmap)bmpIn.Clone(); + if (flipRnB) RGBtoBGR(bmp); + BitmapData texData = bmp.LockBits( + new Rectangle(0, 0, bmp.Width, bmp.Height), + ImageLockMode.ReadOnly, + PixelFormat.Format32bppArgb + ); + return texData; + } + + public static NotificationBitmap_t NotificationBitmapFromBitmapData(BitmapData TextureData) + { + NotificationBitmap_t notification_icon = new(); + notification_icon.m_pImageData = TextureData.Scan0; + notification_icon.m_nWidth = TextureData.Width; + notification_icon.m_nHeight = TextureData.Height; + notification_icon.m_nBytesPerPixel = 4; + return notification_icon; + } + + public static void PointerFromBitmap(Bitmap bmpIn, bool flipRnB, Action action) + { + Bitmap bmp = (Bitmap)bmpIn.Clone(); + if (flipRnB) RGBtoBGR(bmp); + BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, bmp.PixelFormat); + IntPtr pointer = data.Scan0; + action.Invoke(pointer); + bmp.UnlockBits(data); + } + + private static void RGBtoBGR(Bitmap bmp) + { + // based on https://docs.microsoft.com/en-us/dotnet/api/system.drawing.bitmap.unlockbits?view=netframework-4.8 + + int bytesPerPixel = Bitmap.GetPixelFormatSize(bmp.PixelFormat) / 8; + BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, bmp.PixelFormat); + int bytes = Math.Abs(data.Stride) * bmp.Height; + + IntPtr ptr = data.Scan0; + var rgbValues = new byte[bytes]; + Marshal.Copy(data.Scan0, rgbValues, 0, bytes); + for (int i = 0; i < bytes; i += bytesPerPixel) + { + byte dummy = rgbValues[i]; + rgbValues[i] = rgbValues[i + 2]; + rgbValues[i + 2] = dummy; + } + Marshal.Copy(rgbValues, 0, ptr, bytes); + bmp.UnlockBits(data); + } + } + + public static class UnityUtils + { + public static HmdQuaternion_t MatrixToRotation(HmdMatrix34_t m) + { + // x and y are reversed to flip the rotation in the X axis, to convert OpenVR to Unity + var q = new HmdQuaternion_t(); + q.w = Math.Sqrt(1.0f + m.m0 + m.m5 + m.m10) / 2.0f; + q.x = -((m.m9 - m.m6) / (4 * q.w)); + q.y = -((m.m2 - m.m8) / (4 * q.w)); + q.z = (m.m4 - m.m1) / (4 * q.w); + return q; + } + + public static HmdVector3_t MatrixToPosition(HmdMatrix34_t m) + { + // m11 is reversed to flip the Z axis, to convert OpenVR to Unity + var v = new HmdVector3_t(); + v.v0 = m.m3; + v.v1 = m.m7; + v.v2 = -m.m11; + return v; + } + } + + #endregion + } + + public static class Extensions + { + #region Translation + + public static HmdMatrix34_t Translate(this HmdMatrix34_t mat, HmdVector3_t v, bool localAxis = true) + { + if (!localAxis) return mat.Add(v); + + var translationMatrix = new HmdMatrix34_t + { + m0 = 1, + m5 = 1, + m10 = 1, + m3 = v.v0, + m7 = v.v1, + m11 = v.v2 + }; + + return mat.Multiply(translationMatrix); + } + + public static HmdMatrix34_t Translate(this HmdMatrix34_t mat, float x, float y, float z, bool localAxis = true) + { + var translationVector = new HmdVector3_t + { + v0 = x, + v1 = y, + v2 = z + }; + + return mat.Translate(translationVector, localAxis); + } + + #endregion + + #region Rotation + + private static HmdMatrix34_t RotationX(double angle, bool degrees = true) + { + if (degrees) angle = (Math.PI * angle / 180.0); + return new HmdMatrix34_t + { + m0 = 1, + m5 = (float)Math.Cos(angle), + m6 = (float)-Math.Sin(angle), + m9 = (float)Math.Sin(angle), + m10 = (float)Math.Cos(angle), + }; + } + private static HmdMatrix34_t RotationY(double angle, bool degrees = true) + { + if (degrees) angle = (Math.PI * angle / 180.0); + return new HmdMatrix34_t + { + m0 = (float)Math.Cos(angle), + m2 = (float)Math.Sin(angle), + m5 = 1, + m8 = (float)-Math.Sin(angle), + m10 = (float)Math.Cos(angle), + }; + } + private static HmdMatrix34_t RotationZ(double angle, bool degrees = true) + { + if (degrees) angle = (Math.PI * angle / 180.0); + return new HmdMatrix34_t + { + m0 = (float)Math.Cos(angle), + m1 = (float)-Math.Sin(angle), + m4 = (float)Math.Sin(angle), + m5 = (float)Math.Cos(angle), + m10 = 1, + }; + } + + public static HmdMatrix34_t RotateX(this HmdMatrix34_t mat, double angle, bool degrees = true) + { + return mat.Multiply(RotationX(angle, degrees)); + } + public static HmdMatrix34_t RotateY(this HmdMatrix34_t mat, double angle, bool degrees = true) + { + return mat.Multiply(RotationY(angle, degrees)); + } + public static HmdMatrix34_t RotateZ(this HmdMatrix34_t mat, double angle, bool degrees = true) + { + return mat.Multiply(RotationZ(angle, degrees)); + } + + public static HmdMatrix34_t Rotate(this HmdMatrix34_t mat, double angleX, double angleY, double angleZ, bool degrees = true) + { + // HmdMatrix34_t rotation = mat.RotateX(angleX, degrees); + // rotation = rotation.RotateY(angleY, degrees); + // rotation = rotation.RotateZ(angleZ, degrees); + return mat.RotateX(angleX, degrees).RotateY(angleY, degrees).RotateZ(angleZ, degrees); + } + + #endregion + + #region Multiplication + + public static HmdMatrix34_t Multiply(this HmdMatrix34_t matA, HmdMatrix34_t matB) => + EasyOpenVRSingleton.Utils.MultiplyMatrixWithMatrix(matA, matB); + + public static HmdMatrix34_t Multiply(this HmdMatrix34_t mat, float val) + { + return new HmdMatrix34_t + { + m0 = mat.m0 * val, m1 = mat.m1 * val, m2 = mat.m2 * val, m3 = mat.m3 * val, + m4 = mat.m4 * val, m5 = mat.m5 * val, m6 = mat.m6 * val, m7 = mat.m7 * val, + m8 = mat.m8 * val, m9 = mat.m9 * val, m10 = mat.m10 * val, m11 = mat.m11 * val + }; + } + + #endregion + + #region Addition + + public static HmdMatrix34_t Add(this HmdMatrix34_t matA, HmdMatrix34_t matB) + { + return new HmdMatrix34_t + { + m0 = matA.m0 + matB.m0, m1 = matA.m1 + matB.m1, m2 = matA.m2 + matB.m2, m3 = matA.m3 + matB.m3, + m4 = matA.m4 + matB.m4, m5 = matA.m5 + matB.m5, m6 = matA.m6 + matB.m6, m7 = matA.m7 + matB.m7, + m8 = matA.m8 + matB.m8, m9 = matA.m9 + matB.m9, m10 = matA.m10 + matB.m10, m11 = matA.m11 + matB.m11, + }; + } + + public static HmdMatrix34_t Add(this HmdMatrix34_t mat, HmdVector3_t vec) + { + mat.m3 += vec.v0; + mat.m7 += vec.v1; + mat.m11 += vec.v2; + return mat; + } + + #endregion + + #region Subtraction + + public static HmdMatrix34_t Subtract(this HmdMatrix34_t matA, HmdMatrix34_t matB) + { + return new HmdMatrix34_t + { + m0 = matA.m0 - matB.m0, m1 = matA.m1 - matB.m1, m2 = matA.m2 - matB.m2, m3 = matA.m3 - matB.m3, + m4 = matA.m4 - matB.m4, m5 = matA.m5 - matB.m5, m6 = matA.m6 - matB.m6, m7 = matA.m7 - matB.m7, + m8 = matA.m8 - matB.m8, m9 = matA.m9 - matB.m9, m10 = matA.m10 - matB.m10, m11 = matA.m11 - matB.m11, + }; + } + + public static HmdMatrix34_t Subtract(this HmdMatrix34_t mat, HmdVector3_t vec) + { + mat.m3 -= vec.v0; + mat.m7 -= vec.v1; + mat.m11 -= vec.v2; + return mat; + } + + #endregion + + #region Interpolation + + public static HmdMatrix34_t Lerp(this HmdMatrix34_t matA, HmdMatrix34_t matB, float amount) + { + return new HmdMatrix34_t + { + // Row one + m0 = matA.m0 + ((matB.m0 - matA.m0) * amount), + m1 = matA.m1 + ((matB.m1 - matA.m1) * amount), + m2 = matA.m2 + ((matB.m2 - matA.m2) * amount), + m3 = matA.m3 + ((matB.m3 - matA.m3) * amount), + + // Row two + m4 = matA.m4 + ((matB.m4 - matA.m4) * amount), + m5 = matA.m5 + ((matB.m5 - matA.m5) * amount), + m6 = matA.m6 + ((matB.m6 - matA.m6) * amount), + m7 = matA.m7 + ((matB.m7 - matA.m7) * amount), + + // Row three + m8 = matA.m8 + ((matB.m8 - matA.m8) * amount), + m9 = matA.m9 + ((matB.m9 - matA.m9) * amount), + m10 = matA.m10 + ((matB.m10 - matA.m10) * amount), + m11 = matA.m11 + ((matB.m11 - matA.m11) * amount), + }; + } + + #endregion + + #region Transformation + + public static HmdVector3_t EulerAngles(this HmdMatrix34_t mat) + { + double yaw = Math.Atan2(mat.m2, mat.m10); + double pitch = -Math.Asin(mat.m6); + double roll = Math.Atan2(mat.m4, mat.m5); + + return new HmdVector3_t + { + v1 = (float) yaw, + v0 = (float) pitch, + v2 = (float) roll + }; + } + + public static HmdMatrix34_t FromEuler(this HmdMatrix34_t mat, HmdVector3_t angles) + { + HmdMatrix34_t Rx = RotationX(angles.v0, false); + HmdMatrix34_t Ry = RotationY(angles.v1, false); + HmdMatrix34_t Rz = RotationZ(angles.v2, false); + + HmdMatrix34_t rotation = Ry.Multiply(Rx).Multiply(Rz); + + mat.m0 = rotation.m0; + mat.m1 = rotation.m1; + mat.m2 = rotation.m2; + mat.m4 = rotation.m4; + mat.m5 = rotation.m5; + mat.m6 = rotation.m6; + mat.m8 = rotation.m8; + mat.m9 = rotation.m9; + mat.m10 = rotation.m10; + + return mat; + } + + #endregion + + #region Acquisition + public static HmdVector3_t GetPosition(this HmdMatrix34_t mat) + { + return new HmdVector3_t + { + v1 = mat.m3, + v0 = mat.m7, + v2 = mat.m11 + }; + } + #endregion + } +} diff --git a/BeatRecorder/Util/OpenVR/EasyOpenVR/README.md b/BeatRecorder/Util/OpenVR/EasyOpenVR/README.md new file mode 100644 index 0000000..f357f61 --- /dev/null +++ b/BeatRecorder/Util/OpenVR/EasyOpenVR/README.md @@ -0,0 +1,19 @@ +# EasyOpenVR +This is a git submodule to make it easier to talk to the OpenVR API using the C# headers. + +## Disclaimer +This is a project where I have collected things I've figured out while trying to interface OpenVR through C#. I'm not a C# programmer by trade so this is closer to a "get something done at all"-project than something hyper optimized. + +As this is a work-in-progress things might get renamed along the way, hopefully I can keep that to a minimum as I do use this myself, in multiple projects. If nothing else this can act as a place to reference how to call certain OpenVR API functions from C#, some of them took me some time to figure out. + +## Installation +1. To use this either download the repo/file directly or add it as a git submodule by running the following command in the root of your project, replace `TargetFolder` with your own value: `git submodule add https://github.com/BOLL7708/EasyOpenVR.git TargetFolder/EasyOpenVR` +2. Download these dependencies from [OpenVR](https://github.com/ValveSoftware/openvr), the files are [openvr_api.cs](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_api.cs) and [openvr_api.dll](https://github.com/ValveSoftware/openvr/blob/master/bin/win64/openvr_api.dll), put them in the root of your project. +3. Include the files in your project and set the `.dll` to `Copy always` and build your project for 64bit. + +## Usage +1. To use the singleton class, which is my current approach, simply include the namespace by adding `using BOLL7708;` and then grab an instance from `EasyOpenVRSingleton.Instance` +2. If you want your application to be something else than `VRApplication_Background` you can set that with `instance.SetApplicationType()` +3. With the instance run `instance.Init()` to connect to a running OpenVR session. It will return true if initialization was successful, otherwise run a timer and try to init as often as you see fit. +4. At this point, if you have a connected session, you can explore the various calls you can do. +5. If your project is missing for example `System.Drawing`, check References in your project, choose "_Add reference..._" and check `System.Drawing` for inclusion. diff --git a/BeatRecorder/Util/OpenVR/SteamNotifications.cs b/BeatRecorder/Util/OpenVR/SteamNotifications.cs new file mode 100644 index 0000000..6da14a0 --- /dev/null +++ b/BeatRecorder/Util/OpenVR/SteamNotifications.cs @@ -0,0 +1,113 @@ +using BeatRecorder.Entities; +using BeatRecorder.Enums; + +namespace BeatRecorder.Util.OpenVR; + +public class SteamNotifications +{ + List NotificationList = new(); + ulong SteamNotificationId = 0; + + public static SteamNotifications Initialize() + { + SteamNotifications instance = new(); + + _ = Task.Run(() => + { + _logger.LogInfo("Loading Notification Assets.."); + Bitmap InfoIcon; + Bitmap ErrorIcon; + + try + { + InfoIcon = new($"{AppDomain.CurrentDomain.BaseDirectory}Assets\\Info.png"); + ErrorIcon = new($"{AppDomain.CurrentDomain.BaseDirectory}Assets\\Error.png"); + } + catch (Exception ex) + { + _logger.LogFatal("Failed load Notifaction Assets", ex); + return; + } + + while (true) + { + try + { + if (instance.SteamNotificationId == 0) + { + _logger.LogDebug($"Initializing OpenVR.."); + bool Initialized = false; + + while (!Initialized) + { + Initialized = EasyOpenVRSingleton.Instance.Init(); + Thread.Sleep(500); + } + + _logger.LogDebug($"Initialized OpenVR."); + + _logger.LogDebug($"Initializing NotificationOverlay.."); + instance.SteamNotificationId = EasyOpenVRSingleton.Instance.InitNotificationOverlay("BeatRecorder"); + _logger.LogDebug($"Initialized NotificationOverlay: {instance.SteamNotificationId}"); + } + + while (instance.NotificationList.Count == 0) + Thread.Sleep(500); + + NotificationBitmap_t NotifactionIcon; + + foreach (var b in instance.NotificationList.ToList()) + { + BitmapData TextureData = new(); + + if (b.Type == MessageType.INFO) + TextureData = InfoIcon.LockBits(new Rectangle(0, 0, InfoIcon.Width, InfoIcon.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + else if (b.Type == MessageType.ERROR) + TextureData = ErrorIcon.LockBits(new Rectangle(0, 0, ErrorIcon.Width, ErrorIcon.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + + NotifactionIcon.m_pImageData = TextureData.Scan0; + NotifactionIcon.m_nWidth = TextureData.Width; + NotifactionIcon.m_nHeight = TextureData.Height; + NotifactionIcon.m_nBytesPerPixel = 4; + + var NotificationId = EasyOpenVRSingleton.Instance.EnqueueNotification(instance.SteamNotificationId, EVRNotificationType.Persistent, b.Message, EVRNotificationStyle.Application, NotifactionIcon); + _logger.LogDebug($"Displayed Notification {NotificationId}: {b.Message}"); + + if (b.Type == MessageType.INFO) + InfoIcon.UnlockBits(TextureData); + else if (b.Type == MessageType.ERROR) + ErrorIcon.UnlockBits(TextureData); + + if (NotificationId == 0) + return; + + Thread.Sleep(b.Delay); + EasyOpenVRSingleton.Instance.DismissNotification(NotificationId, out var error); + + if (error != EVRNotificationError.OK) + { + _logger.LogFatal($"Failed to dismiss notification {instance.SteamNotificationId}: {error}"); + } + + _logger.LogDebug($"Dismissed Notification {NotificationId}"); + + instance.NotificationList.Remove(b); + } + } + catch (Exception ex) + { + _logger.LogError("Failed to handle notifaction loop", ex); + Thread.Sleep(5000); + continue; + } + } + }); + + return instance; + } + + public void SendNotification(string Text, int DisplayTime = 2000, MessageType messageType = MessageType.INFO) + { + NotificationList.Add(new NotificationEntry { Message = Text, Delay = DisplayTime, Type = messageType }); + } +} diff --git a/BeatRecorder/Util/openvr_api.cs b/BeatRecorder/Util/OpenVR/openvr_api.cs similarity index 99% rename from BeatRecorder/Util/openvr_api.cs rename to BeatRecorder/Util/OpenVR/openvr_api.cs index ae9168d..da692d7 100644 --- a/BeatRecorder/Util/openvr_api.cs +++ b/BeatRecorder/Util/OpenVR/openvr_api.cs @@ -8154,8 +8154,7 @@ static COpenVRContext OpenVRInternal_ModuleContext { get { - if (_OpenVRInternal_ModuleContext == null) - _OpenVRInternal_ModuleContext = new COpenVRContext(); + _OpenVRInternal_ModuleContext ??= new COpenVRContext(); return _OpenVRInternal_ModuleContext; } } diff --git a/BeatRecorder/Util/UIHandler.cs b/BeatRecorder/Util/UIHandler.cs new file mode 100644 index 0000000..fdf2f89 --- /dev/null +++ b/BeatRecorder/Util/UIHandler.cs @@ -0,0 +1,167 @@ +using BeatRecorderUI; +using System.Windows.Forms; +using static Xorog.UniversalExtensions.UniversalExtensionsEnums; + +namespace BeatRecorder.Util; + +public class UIHandler +{ + InfoUI infoUI { get; set; } = null; + + internal static UIHandler Initialize(Program program) + { + var instance = new UIHandler(); + + Thread.Sleep(500); + + if (!program.LoadedConfig.HideConsole) + ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 2); + else + ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 0); + + instance.infoUI = new InfoUI(Program.Version, program.LoadedConfig.DisplayUITopmost); + + _ = Task.Run(async () => + { + var infoUI = instance.infoUI; + + Action UpdateUI = new(() => + { + string WarningText = ""; + + if (program.RunningPrerelease) + WarningText = "You're running a Pre-Release. Please expect bugs or unfinished features.\n"; + + if (program.LoadedConfig.Mod == "beatsaberplus") + WarningText += "You're using BeatSaberPlus, filenames will not reflect whether you finished a song or not."; + + if (infoUI.label1.Text != WarningText) + infoUI.label1.Text = WarningText; + + if (program.ObsClient?.GetIsRunning() ?? false) + { + if (program.ObsClient.GetIsRecording()) + { + if (infoUI.OBSConnectionLabel.Text != "(REC) OBS") + infoUI.OBSConnectionLabel.Text = "(REC) OBS"; + + if (infoUI.OBSConnectionLabel.BackColor != Color.Orange) + infoUI.OBSConnectionLabel.BackColor = Color.Orange; + } + else + { + if (infoUI.OBSConnectionLabel.Text != "OBS") + infoUI.OBSConnectionLabel.Text = "OBS"; + + if (infoUI.OBSConnectionLabel.BackColor != Color.DarkGreen) + infoUI.OBSConnectionLabel.BackColor = Color.DarkGreen; + } + } + else + { + if (infoUI.OBSConnectionLabel.BackColor != Color.DarkRed) + infoUI.OBSConnectionLabel.BackColor = Color.DarkRed; + } + + if (program.BeatSaberClient?.GetIsRunning() ?? false) + { + if (infoUI.BeatSaberConnectionLabel.BackColor != Color.DarkGreen) + infoUI.BeatSaberConnectionLabel.BackColor = Color.DarkGreen; + + var cur = program.BeatSaberClient.GetCurrentStatus(); + + infoUI.SongNameLabel.Text = cur.BeatmapInfo.Name ?? "Title"; + infoUI.SongAuthorLabel.Text = cur.BeatmapInfo.Author ?? "Artist"; + infoUI.SongAuthorLabel.Location = new Point(infoUI.SongNameLabel.Location.X, infoUI.SongNameLabel.Location.Y + infoUI.SongNameLabel.Height); + infoUI.MapperLabel.Text = cur.BeatmapInfo.Creator ?? "Mapper"; + + infoUI.ProgressLabel.Text = $"{TimeSpan.FromSeconds(program.ObsClient.GetRecordingSeconds()).GetShortHumanReadable(TimeFormat.MINUTES)}"; + + infoUI.ScoreLabel.Text = String.Format("{0:n0}", cur.PerformanceInfo.Score); + infoUI.ComboLabel.Text = $"{cur.PerformanceInfo?.Combo ?? 0}x"; + infoUI.AccuracyLabel.Text = $"{cur.PerformanceInfo.Accuracy}%"; + infoUI.MissesLabel.Text = $"{cur.PerformanceInfo.CombinedMisses ?? 0} Misses"; + + if (cur.BeatmapInfo.Cover != null && infoUI.pictureBox1.Image != cur.BeatmapInfo.Cover) + infoUI.pictureBox1.Image = cur.BeatmapInfo.Cover; + + } + else + { + if (infoUI.BeatSaberConnectionLabel.BackColor != Color.DarkRed) + infoUI.BeatSaberConnectionLabel.BackColor = Color.DarkRed; + } + }); + + while (true) + { + try + { + if (infoUI.InvokeRequired) + infoUI.Invoke(UpdateUI); + else + UpdateUI(); + + await Task.Delay(1000); + } + catch { } + } + }); + + _ = Task.Run(() => + { + _logger.LogDebug("Initializing GUI.."); + + instance.infoUI.ShowDialog(); + ConsoleHelper.ShowWindow(ConsoleHelper.GetConsoleWindow(), 5); + + if (instance.infoUI.ShowConsoleAgain) + { + instance.infoUI.ShowConsoleAgain = false; + instance.infoUI.ShowConsole.Visible = false; + + instance.infoUI.ShowDialog(); + } + + if (instance.infoUI.SettingsUpdated) + { + _logger.LogDebug("Settings updated via GUI"); + Process.Start(Environment.ProcessPath); + Thread.Sleep(500); + Environment.Exit(0); + } + + _logger.LogDebug("InfoUI closed"); + Environment.Exit(0); + }); + + return instance; + } + + internal void ShowNotification(string Description, string Title = "", MessageBoxButtons messageBoxButtons = MessageBoxButtons.OK, MessageBoxIcon messageBoxIcon = MessageBoxIcon.None) + { + Action action = new(() => + { + MessageBox.Show(Description, Title, messageBoxButtons, messageBoxIcon); + }); + + if (infoUI.InvokeRequired) + infoUI.Invoke(action); + else + action(); + } + + internal void ShowSettings(bool Required = false) + { + Action action = new(() => + { + infoUI.SettingsRequired = Required; + infoUI.OpenSettings_Click(null, null); + }); + + if (infoUI.InvokeRequired) + infoUI.Invoke(action); + else + action(); + } +} diff --git a/BeatRecorderUI/BeatRecorderUI.csproj b/BeatRecorderUI/BeatRecorderUI.csproj index 96f5642..a29c62c 100644 --- a/BeatRecorderUI/BeatRecorderUI.csproj +++ b/BeatRecorderUI/BeatRecorderUI.csproj @@ -3,25 +3,20 @@ WinExe net6.0-windows - disable - true - true true true win-x64 + disable + true enable - - embedded - - - - embedded - - + + + + \ No newline at end of file diff --git a/BeatRecorderUI/Entities/Config.cs b/BeatRecorderUI/Entities/Config.cs new file mode 120000 index 0000000..bf47346 --- /dev/null +++ b/BeatRecorderUI/Entities/Config.cs @@ -0,0 +1 @@ +../../BeatRecorder/Entities/Config.cs \ No newline at end of file diff --git a/BeatRecorderUI/InfoUI.Designer.cs b/BeatRecorderUI/InfoUI.Designer.cs index e8c9ce6..9d804df 100644 --- a/BeatRecorderUI/InfoUI.Designer.cs +++ b/BeatRecorderUI/InfoUI.Designer.cs @@ -33,7 +33,6 @@ private void InitializeComponent() this.SongNameLabel = new System.Windows.Forms.Label(); this.SongAuthorLabel = new System.Windows.Forms.Label(); this.MapperLabel = new System.Windows.Forms.Label(); - this.BSRLabel = new System.Windows.Forms.Label(); this.OpenSettings = new System.Windows.Forms.Button(); this.ScoreLabel = new System.Windows.Forms.Label(); this.ComboLabel = new System.Windows.Forms.Label(); @@ -45,7 +44,6 @@ private void InitializeComponent() this.label1 = new System.Windows.Forms.Label(); this.ShowConsole = new System.Windows.Forms.Button(); this.Restart = new System.Windows.Forms.Button(); - this.label2 = new System.Windows.Forms.Label(); this.CheckForUpdates = new System.Windows.Forms.Button(); this.VersionLabel = new System.Windows.Forms.Label(); ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); @@ -86,22 +84,12 @@ private void InitializeComponent() // this.MapperLabel.AutoSize = true; this.MapperLabel.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - this.MapperLabel.Location = new System.Drawing.Point(257, 234); + this.MapperLabel.Location = new System.Drawing.Point(255, 217); this.MapperLabel.Name = "MapperLabel"; this.MapperLabel.Size = new System.Drawing.Size(48, 15); this.MapperLabel.TabIndex = 3; this.MapperLabel.Text = "Mapper"; // - // BSRLabel - // - this.BSRLabel.AutoSize = true; - this.BSRLabel.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - this.BSRLabel.Location = new System.Drawing.Point(257, 217); - this.BSRLabel.Name = "BSRLabel"; - this.BSRLabel.Size = new System.Drawing.Size(27, 15); - this.BSRLabel.TabIndex = 4; - this.BSRLabel.Text = "BSR"; - // // OpenSettings // this.OpenSettings.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(84)))), ((int)(((byte)(0)))), ((int)(((byte)(84))))); @@ -187,14 +175,13 @@ private void InitializeComponent() // // label1 // - this.label1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Italic, System.Drawing.GraphicsUnit.Point); - this.label1.ForeColor = System.Drawing.Color.Gray; - this.label1.Location = new System.Drawing.Point(255, 293); + this.label1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point); + this.label1.ForeColor = System.Drawing.Color.Gold; + this.label1.Location = new System.Drawing.Point(255, 232); this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(371, 17); + this.label1.Size = new System.Drawing.Size(371, 78); this.label1.TabIndex = 13; - this.label1.Text = "You can disable the new UI in the advanced settings"; - this.label1.TextAlign = System.Drawing.ContentAlignment.MiddleRight; + this.label1.TextAlign = System.Drawing.ContentAlignment.BottomRight; // // ShowConsole // @@ -222,18 +209,6 @@ private void InitializeComponent() this.Restart.UseVisualStyleBackColor = false; this.Restart.Click += new System.EventHandler(this.Restart_Click); // - // label2 - // - this.label2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Italic, System.Drawing.GraphicsUnit.Point); - this.label2.ForeColor = System.Drawing.Color.Gray; - this.label2.Location = new System.Drawing.Point(255, 262); - this.label2.Name = "label2"; - this.label2.Size = new System.Drawing.Size(371, 31); - this.label2.TabIndex = 16; - this.label2.Text = "To find out why something isn\'t working, you can check your log files. It\'s worth" + - " a shot."; - this.label2.TextAlign = System.Drawing.ContentAlignment.MiddleRight; - // // CheckForUpdates // this.CheckForUpdates.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(84)))), ((int)(((byte)(0)))), ((int)(((byte)(84))))); @@ -266,7 +241,6 @@ private void InitializeComponent() this.ClientSize = new System.Drawing.Size(784, 321); this.Controls.Add(this.VersionLabel); this.Controls.Add(this.CheckForUpdates); - this.Controls.Add(this.label2); this.Controls.Add(this.Restart); this.Controls.Add(this.ShowConsole); this.Controls.Add(this.label1); @@ -278,7 +252,6 @@ private void InitializeComponent() this.Controls.Add(this.ComboLabel); this.Controls.Add(this.ScoreLabel); this.Controls.Add(this.OpenSettings); - this.Controls.Add(this.BSRLabel); this.Controls.Add(this.MapperLabel); this.Controls.Add(this.SongAuthorLabel); this.Controls.Add(this.SongNameLabel); @@ -307,7 +280,6 @@ private void InitializeComponent() public Label SongNameLabel; public Label SongAuthorLabel; public Label MapperLabel; - public Label BSRLabel; public Label ScoreLabel; public Label ComboLabel; public Label MissesLabel; @@ -316,7 +288,6 @@ private void InitializeComponent() public Label label1; public Button ShowConsole; private Button Restart; - public Label label2; private Button CheckForUpdates; public Label VersionLabel; } diff --git a/BeatRecorderUI/InfoUI.cs b/BeatRecorderUI/InfoUI.cs index 01835b1..95892e4 100644 --- a/BeatRecorderUI/InfoUI.cs +++ b/BeatRecorderUI/InfoUI.cs @@ -7,37 +7,32 @@ public partial class InfoUI : Form bool loadedTopmost = false; - bool SettingsRequired = false; + public bool SettingsRequired = false; - public InfoUI(string version, bool alwaysTopMost = false, bool settingsRequired = false) + public InfoUI(string version, bool alwaysTopMost = false) { InitializeComponent(); loadedTopmost = alwaysTopMost; - SettingsRequired = settingsRequired; VersionLabel.Text = $"v{version}"; } private void InfoUI_Shown(object sender, EventArgs e) { this.TopMost = loadedTopmost; - - if (SettingsRequired) - { - SettingsUI settingsUI = new(this.TopMost); - this.Hide(); - settingsUI.ShowDialog(); - this.Close(); - } } - private void OpenSettings_Click(object sender, EventArgs e) + public void OpenSettings_Click(object sender, EventArgs e) { SettingsUI settingsUI = new(this.TopMost); + + if (SettingsRequired) + this.Hide(); + settingsUI.ShowDialog(); - if (settingsUI.SettingsUpdated) + if (settingsUI.SettingsUpdated || SettingsRequired) { SettingsUpdated = true; this.Close(); diff --git a/BeatRecorderUI/LoglevelEnum.cs b/BeatRecorderUI/LoglevelEnum.cs deleted file mode 100644 index a7ea4f5..0000000 --- a/BeatRecorderUI/LoglevelEnum.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace BeatRecorder; - -internal class LoglevelEnum -{ - public enum LogLevel - { - FATAL, - ERROR, - WARN, - INFO, - DEBUG, - NONE - } -} diff --git a/BeatRecorderUI/Objects.cs b/BeatRecorderUI/Objects.cs deleted file mode 100644 index c9f0f52..0000000 --- a/BeatRecorderUI/Objects.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace BeatRecorder; - -class Objects -{ - public static bool SettingsRequired = false; - public static bool UpdateAvailable = false; - public static string UpdateText = ""; - - public static ulong SteamNotificationId = 0; - - public static ConnectionTypeWarning LastDP1Warning { get; set; } - public static ConnectionTypeWarning LastHttpStatusWarning { get; set; } - public static ConnectionTypeWarning LastOBSWarning { get; set; } - - public enum ConnectionTypeWarning - { - CONNECTED, - MOD_INSTALLED, - MOD_NOT_INSTALLED, - NOT_MODDED, - NO_PROCESS - } - - public static Settings LoadedSettings = new(); - - public class Settings - { - public string README { get; set; } = "!! Please check https://github.com/TheXorog/BeatRecorder for more info and explainations for each config options !!"; - public LogLevel ConsoleLogLevelEnum { get; set; } = LogLevel.INFO; - public string Mod { get; set; } = "http-status"; - public bool DisplayUI { get; set; } = true; - public bool DisplayUITopmost { get; set; } = true; - public bool HideConsole { get; set; } = true; - public bool AutomaticRecording { get; set; } = true; - public bool DisplaySteamNotifications { get; set; } = false; - public string BeatSaberUrl { get; set; } = "127.0.0.1"; - public string BeatSaberPort { get; set; } = "6557"; - public string OBSUrl { get; set; } = "127.0.0.1"; - public string OBSPort { get; set; } = "4444"; - public string OBSPassword { get; set; } = ""; - public int MininumWaitUntilRecordingCanStart { get; set; } = 500; - public bool AskToSaveOBSPassword { get; set; } = true; - public bool PauseRecordingOnIngamePause { get; set; } = false; - public string FileFormat { get; set; } = "[][][x] - []"; - public int StopRecordingDelay { get; set; } = 5; - public int DeleteIfShorterThan { get; set; } = 0; - public bool DeleteQuit { get; set; } = false; - public bool DeleteIfQuitAfterSoftFailed { get; set; } = false; - public bool DeleteFailed { get; set; } = false; - public bool DeleteSoftFailed { get; set; } = false; - public string OBSMenuScene { get; set; } = ""; - public string OBSIngameScene { get; set; } = ""; - public string OBSPauseScene { get; set; } = ""; - } - - // Shedule-based logger - - public static List LogsToPost = new(); - - public class LogEntry - { - public DateTime TimeOfEvent { get; set; } - public int LogLevel { get; set; } - public int LogCount { get; set; } - public string Message { get; set; } - } - - public enum MessageType - { - ERROR, - INFO - } - - public class NotificationEntry - { - public string Message { get; set; } - public int Delay { get; set; } = 2000; - public MessageType Type { get; set; } = MessageType.INFO; - } -} diff --git a/BeatRecorderUI/SettingsUI.Designer.cs b/BeatRecorderUI/SettingsUI.Designer.cs index 6fce801..ba7a8fc 100644 --- a/BeatRecorderUI/SettingsUI.Designer.cs +++ b/BeatRecorderUI/SettingsUI.Designer.cs @@ -49,6 +49,7 @@ private void InitializeComponent() this.difficultyToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.shortDifficultyToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.songNameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.songNameWithSubNameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.songAuthorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.songSubNameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.mapperToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -86,12 +87,14 @@ private void InitializeComponent() this.label14 = new System.Windows.Forms.Label(); this.label15 = new System.Windows.Forms.Label(); this.label16 = new System.Windows.Forms.Label(); - this.songNameWithSubNameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.OBSLegacyPortBox = new System.Windows.Forms.NumericUpDown(); + this.label1 = new System.Windows.Forms.Label(); ((System.ComponentModel.ISupportInitialize)(this.BeatSaberPortBox)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.OBSPortBox)).BeginInit(); this.contextMenuStrip1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.StopRecordingDelay)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.DeleteIfShorterThan)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.OBSLegacyPortBox)).BeginInit(); this.SuspendLayout(); // // BeatSaberIpBox @@ -211,7 +214,8 @@ private void InitializeComponent() this.ModSelectionBox.FormattingEnabled = true; this.ModSelectionBox.Items.AddRange(new object[] { "http-status", - "datapuller"}); + "datapuller", + "beatsaberplus"}); this.ModSelectionBox.Location = new System.Drawing.Point(12, 29); this.ModSelectionBox.Name = "ModSelectionBox"; this.ModSelectionBox.Size = new System.Drawing.Size(360, 23); @@ -301,7 +305,7 @@ private void InitializeComponent() this.scoreToolStripMenuItem, this.rawScoreToolStripMenuItem}); this.contextMenuStrip1.Name = "contextMenuStrip1"; - this.contextMenuStrip1.Size = new System.Drawing.Size(221, 356); + this.contextMenuStrip1.Size = new System.Drawing.Size(221, 334); // // difficultyToolStripMenuItem // @@ -324,6 +328,13 @@ private void InitializeComponent() this.songNameToolStripMenuItem.Text = "Song Name"; this.songNameToolStripMenuItem.Click += new System.EventHandler(this.songNameToolStripMenuItem_Click); // + // songNameWithSubNameToolStripMenuItem + // + this.songNameWithSubNameToolStripMenuItem.Name = "songNameWithSubNameToolStripMenuItem"; + this.songNameWithSubNameToolStripMenuItem.Size = new System.Drawing.Size(220, 22); + this.songNameWithSubNameToolStripMenuItem.Text = "Song Name with Sub Name"; + this.songNameWithSubNameToolStripMenuItem.Click += new System.EventHandler(this.songNameWithSubNameToolStripMenuItem_Click); + // // songAuthorToolStripMenuItem // this.songAuthorToolStripMenuItem.Name = "songAuthorToolStripMenuItem"; @@ -553,7 +564,7 @@ private void InitializeComponent() this.DisplayUserInterfaceCheck.FlatAppearance.BorderSize = 0; this.DisplayUserInterfaceCheck.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.DisplayUserInterfaceCheck.ForeColor = System.Drawing.Color.White; - this.DisplayUserInterfaceCheck.Location = new System.Drawing.Point(412, 201); + this.DisplayUserInterfaceCheck.Location = new System.Drawing.Point(412, 247); this.DisplayUserInterfaceCheck.Name = "DisplayUserInterfaceCheck"; this.DisplayUserInterfaceCheck.Size = new System.Drawing.Size(189, 19); this.DisplayUserInterfaceCheck.TabIndex = 30; @@ -581,7 +592,7 @@ private void InitializeComponent() this.AutomaticRecordingCheck.FlatAppearance.BorderSize = 0; this.AutomaticRecordingCheck.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.AutomaticRecordingCheck.ForeColor = System.Drawing.Color.White; - this.AutomaticRecordingCheck.Location = new System.Drawing.Point(412, 226); + this.AutomaticRecordingCheck.Location = new System.Drawing.Point(412, 272); this.AutomaticRecordingCheck.Name = "AutomaticRecordingCheck"; this.AutomaticRecordingCheck.Size = new System.Drawing.Size(134, 19); this.AutomaticRecordingCheck.TabIndex = 32; @@ -677,7 +688,7 @@ private void InitializeComponent() this.EntirelyHideConsoleCheck.FlatAppearance.BorderSize = 0; this.EntirelyHideConsoleCheck.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.EntirelyHideConsoleCheck.ForeColor = System.Drawing.Color.White; - this.EntirelyHideConsoleCheck.Location = new System.Drawing.Point(412, 251); + this.EntirelyHideConsoleCheck.Location = new System.Drawing.Point(412, 297); this.EntirelyHideConsoleCheck.Name = "EntirelyHideConsoleCheck"; this.EntirelyHideConsoleCheck.Size = new System.Drawing.Size(136, 19); this.EntirelyHideConsoleCheck.TabIndex = 36; @@ -728,12 +739,32 @@ private void InitializeComponent() this.label16.Text = "You can hover over items to get more details"; this.label16.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; // - // songNameWithSubNameToolStripMenuItem + // OBSLegacyPortBox // - this.songNameWithSubNameToolStripMenuItem.Name = "songNameWithSubNameToolStripMenuItem"; - this.songNameWithSubNameToolStripMenuItem.Size = new System.Drawing.Size(220, 22); - this.songNameWithSubNameToolStripMenuItem.Text = "Song Name with Sub Name"; - this.songNameWithSubNameToolStripMenuItem.Click += new System.EventHandler(this.songNameWithSubNameToolStripMenuItem_Click); + this.OBSLegacyPortBox.BackColor = System.Drawing.Color.Black; + this.OBSLegacyPortBox.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.OBSLegacyPortBox.Enabled = false; + this.OBSLegacyPortBox.ForeColor = System.Drawing.Color.White; + this.OBSLegacyPortBox.Location = new System.Drawing.Point(412, 218); + this.OBSLegacyPortBox.Maximum = new decimal(new int[] { + 65535, + 0, + 0, + 0}); + this.OBSLegacyPortBox.Name = "OBSLegacyPortBox"; + this.OBSLegacyPortBox.Size = new System.Drawing.Size(360, 23); + this.OBSLegacyPortBox.TabIndex = 45; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point); + this.label1.ForeColor = System.Drawing.Color.White; + this.label1.Location = new System.Drawing.Point(412, 198); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(190, 17); + this.label1.TabIndex = 44; + this.label1.Text = "(Legacy) OBS Websocket Port"; // // SettingsUI // @@ -741,6 +772,8 @@ private void InitializeComponent() this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(23)))), ((int)(((byte)(23)))), ((int)(((byte)(23))))); this.ClientSize = new System.Drawing.Size(784, 691); + this.Controls.Add(this.OBSLegacyPortBox); + this.Controls.Add(this.label1); this.Controls.Add(this.label16); this.Controls.Add(this.label15); this.Controls.Add(this.PauseSceneBox); @@ -794,6 +827,7 @@ private void InitializeComponent() this.contextMenuStrip1.ResumeLayout(false); ((System.ComponentModel.ISupportInitialize)(this.StopRecordingDelay)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.DeleteIfShorterThan)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.OBSLegacyPortBox)).EndInit(); this.ResumeLayout(false); this.PerformLayout(); @@ -858,4 +892,6 @@ private void InitializeComponent() private TextBox PauseSceneBox; public Label label16; private ToolStripMenuItem songNameWithSubNameToolStripMenuItem; + private NumericUpDown OBSLegacyPortBox; + private Label label1; } diff --git a/BeatRecorderUI/SettingsUI.cs b/BeatRecorderUI/SettingsUI.cs index e252a1d..0bd09c1 100644 --- a/BeatRecorderUI/SettingsUI.cs +++ b/BeatRecorderUI/SettingsUI.cs @@ -16,7 +16,7 @@ public SettingsUI(bool topMost, bool settingsRequired = false) SettingsRequired = settingsRequired; } - BeatRecorder.Objects.Settings _loadedSettings = null; + BeatRecorder.Entities.Config _loadedSettings = null; private void SettingsUI_Shown(object sender, EventArgs e) { @@ -29,12 +29,13 @@ private void SettingsUI_Shown(object sender, EventArgs e) OBSIpBox.Enabled = false; OBSPortBox.Enabled = false; + OBSLegacyPortBox.Enabled = false; DisplayUserInterfaceCheck.Enabled = false; AutomaticRecordingCheck.Enabled = false; if (File.Exists("Settings.json")) - _loadedSettings = JsonConvert.DeserializeObject(File.ReadAllText("Settings.json")); + _loadedSettings = JsonConvert.DeserializeObject(File.ReadAllText("Settings.json")); if (_loadedSettings == null) { @@ -67,7 +68,8 @@ private void SettingsUI_Shown(object sender, EventArgs e) BeatSaberIpBox.Text = _loadedSettings.BeatSaberUrl; BeatSaberPortBox.Text = _loadedSettings.BeatSaberPort; OBSIpBox.Text = _loadedSettings.OBSUrl; - OBSPortBox.Text = _loadedSettings.OBSPort; + OBSPortBox.Text = _loadedSettings.OBSPortModern; + OBSLegacyPortBox.Text = _loadedSettings.OBSPortLegacy; DisplayUserInterfaceCheck.Checked = _loadedSettings.DisplayUI; AutomaticRecordingCheck.Checked = _loadedSettings.AutomaticRecording; PauseOnIngamePauseCheck.Checked = _loadedSettings.PauseRecordingOnIngamePause; @@ -101,6 +103,7 @@ private void ShowAdvancedSettings_Click(object sender, EventArgs e) OBSIpBox.Enabled = true; OBSPortBox.Enabled = true; + OBSLegacyPortBox.Enabled = true; DisplayUserInterfaceCheck.Enabled = true; AutomaticRecordingCheck.Enabled = true; @@ -115,6 +118,7 @@ private void ShowAdvancedSettings_Click(object sender, EventArgs e) OBSIpBox.Enabled = false; OBSPortBox.Enabled = false; + OBSLegacyPortBox.Enabled = false; DisplayUserInterfaceCheck.Enabled = false; AutomaticRecordingCheck.Enabled = false; @@ -146,9 +150,13 @@ private void ModSelectionBox_SelectedIndexChanged(object sender, EventArgs e) if (ModSelectionBox.Text == "datapuller") BeatSaberPortBox.Value = 2946; - - if (ModSelectionBox.Text == "http-status") + else if (ModSelectionBox.Text == "http-status") BeatSaberPortBox.Value = 6557; + else if (ModSelectionBox.Text == "beatsaberplus") + { + _ = Task.Run(() => { MessageBox.Show("BeatSaberPlus Integration is currently incomplete. Filenames will always appear as if you finished the song.", "Warning", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Warning); }); + BeatSaberPortBox.Value = 2947; + } } private void ModSelectionBox_TextChanged(object sender, EventArgs e) @@ -165,9 +173,13 @@ private void ModSelectionBox_TextUpdate(object sender, EventArgs e) if (ModSelectionBox.Text == "datapuller") BeatSaberPortBox.Value = 2946; - - if (ModSelectionBox.Text == "http-status") + else if (ModSelectionBox.Text == "http-status") BeatSaberPortBox.Value = 6557; + else if (ModSelectionBox.Text == "beatsaberplus") + { + _ = Task.Run(() => { MessageBox.Show("BeatSaberPlus Integration is currently incomplete. Filenames will always appear as if you finished the song.", "Warning", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Warning); }); + BeatSaberPortBox.Value = 2947; + } } private void Cancel_Click(object sender, EventArgs e) @@ -201,7 +213,8 @@ private void Save_Click(object sender, EventArgs e) _loadedSettings.BeatSaberUrl = BeatSaberIpBox.Text; _loadedSettings.BeatSaberPort = BeatSaberPortBox.Text; _loadedSettings.OBSUrl = OBSIpBox.Text; - _loadedSettings.OBSPort = OBSPortBox.Text; + _loadedSettings.OBSPortModern = OBSPortBox.Text; + _loadedSettings.OBSPortLegacy = OBSLegacyPortBox.Text; _loadedSettings.DisplayUI = DisplayUserInterfaceCheck.Checked; _loadedSettings.AutomaticRecording = AutomaticRecordingCheck.Checked; _loadedSettings.PauseRecordingOnIngamePause = PauseOnIngamePauseCheck.Checked; diff --git a/BeatRecorderUI/Usings.cs b/BeatRecorderUI/Usings.cs index 07d3539..783e7ad 100644 --- a/BeatRecorderUI/Usings.cs +++ b/BeatRecorderUI/Usings.cs @@ -18,6 +18,4 @@ global using System.ComponentModel; -global using static BeatRecorder.LoglevelEnum; - global using Newtonsoft.Json; \ No newline at end of file diff --git a/README.md b/README.md index aca24a0..c906aaf 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,12 @@ -# drawing BeatRecorder +

BeatRecorder

+

+

Easily record your BeatSaber gameplay!

+

+## What is BeatRecorder? -## Table of Contents - -#### General info and instructions -* **[What is this?](#what-is-this)** -* **[Requirements](#requirements)** -* **[How to set up](#how-to-set-up)** -#### Config -* **[Config Help](#config-help)** -* **[Filename Placeholders](#filename-placeholders)** -#### Troubleshooting -* **[BeatRecorder not connecting?](#beatrecorder-not-connecting)** -* **[Notable differences between beatsaber-http-status and BSDataPuller](#notable-differences-between-beatsaber-http-status-and-bsdatapuller)** -* **[Contributing or forking](#contributing-or-forking)** - -## Important info regarding OBS Websocket 5.x.x - -**OBS Websocket 5.X.X is currently not supported. Please download and use [4.9.1](https://github.com/obsproject/obs-websocket/releases/tag/4.9.1-compat) for now.** 5.x.x support will be added soon, so stay tuned for that. - -## What is this? - -This application is for people who record their BeatSaber gameplay using OBS. It connects to **[beatsaber-http-status](https://github.com/opl-/beatsaber-http-status/)** or **[BSDataPuller](https://github.com/kOFReadie/BSDataPuller)** to detect the current game-state and **[obs-websocket](https://github.com/obsproject/obs-websocket/releases/tag/4.9.1)** to automatically start and stop the recording. +This application is for people who record their BeatSaber gameplay using OBS. It connects to **[HttpSiraStatus](https://github.com/denpadokei/HttpSiraStatus)** (**[beatsaber-http-status](https://github.com/opl-/beatsaber-http-status/)** for older Beat Saber versions), **[BSDataPuller](https://github.com/kOFReadie/BSDataPuller)** or **[BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)** to detect the current game-state and **[obs-websocket](https://github.com/obsproject/obs-websocket/releases/)** to automatically start and stop the recording. Files are saved where-ever you set your output folder to in OBS. @@ -33,88 +17,28 @@ Files are saved where-ever you set your output folder to in OBS. ## Requirements -* **[beatsaber-http-status](https://github.com/opl-/beatsaber-http-status/)** or **[BSDataPuller](https://github.com/kOFReadie/BSDataPuller)** +* **[HttpSiraStatus](https://github.com/denpadokei/HttpSiraStatus)** (**[HttpStatus](https://github.com/opl-/beatsaber-http-status/)** for older BS versions), **[BSDataPuller](https://github.com/kOFReadie/BSDataPuller)** or **[BeatSaberPlus (experimental)](https://github.com/hardcpp/BeatSaberPlus)** + - **BeatSaberPlus support is incomplete as it does not provide a way of knowing if a song was failed or finished.**

-* **[obs-websocket](https://github.com/obsproject/obs-websocket/releases/tag/4.9.1)** +* **[obs-websocket v5.0+ or v4.9.1](https://github.com/obsproject/obs-websocket/releases/)** ## How to set up 1. Install all the previously mentioned dependencies. 2. Download and unzip this application. -3. Run it once, it'll ask you to input your Settings. Do so. - 1. **If you want to use BSDataPuller instead of beatsaber-http-status, set `Mod` to `datapuller` and change the `BeatSaberPort` to the port that datapuller uses (Default: `2946`)** -4. After configuring, close notepad and check if the applications starts up. -5. Profit - -## Config Help - -* `README` - This really just redirects you here for help. -

-* `ConsoleLogLevel` - The log level displayed in the console. I recommend leaving that alone. -

-* `Mod` - The mod you want to use to connect to Beat Saber. (`http-status`, `datapuller`) -* `BeatSaberUrl` - The IP/URL you use to connect to Beat Saber. Leave at default if you don't know any better. -* `BeatSaberPort` - The Port you use to connect to Beat Saber. If you don't know any better, choose one of these ports depending on the mod you selected: `http-status`:`6557`, `datapuller`:`2946` -

-* `OBSUrl` - The IP/URL you use to connect to the OBS-WebSocket. Leave at default if you don't know any better. -* `OBSPort` - The IP/URL you use to connect to the OBS-WebSocket. Leave at default if you don't know any better. -* `OBSPassword` - The password you use to connect to the OBS-Websocket. BeatRecorder will ask for the password if you haven't put one in. -* `AskToSaveOBSPassword` - This specifies if you want BeatRecorder to ask for a password. -* `DisplaySteamNotifications` - Toggles sending of steam notifications when starting/stopping recordings, and other events. (EXPERIMENTAL!) -

-* `MininumWaitUntilRecordingCanStart` - The amount of milliseconds a new recording is gonna be delayed when forcing to restart a recording. Increase this if you find that your recordings don't restart after restarting a song. (min: `200`ms, max: `2000`ms) -

-* `PauseRecordingOnIngamePause` - Should your recording be paused when you pause your current song? -* `FileFormat` - The format your files will be renamed to. Check Filename Placeholders below for more info. -* `StopRecordingDelay` - How many seconds BeatRecorder should wait after you finish a song/leave the song to end the recording. (min: `1` second, max: `20` seconds) -

-* `DeleteIfShorterThan` - Delete the file if the recording is shorter than the specified amount of seconds. -* `DeleteQuit` - Delete the file if you quit the song. -* `DeleteIfQuitAfterSoftFailed` - Delete the file if you quit the song after losing all your health with no fail. -* `DeleteFailed` - Delete the file if you fail the song. -* `DeleteSoftFailed` - Delete the file if you soft-fail¹ the song. -

-1: _Soft-Failing means losing all your health with no fail on._ - - -## Filename Placeholders - -The current default is: `[][][x] - []` -* `` - The difficulty (e.g. `Expert`, `Expert+`) (`1.2.0+`) -* `` - The difficulty but shorter (e.g. `EX`, `EX+`) (`1.2.0+`) -* `` - The song name -* `` - The song author -* `` - The subtitle of the song (e.g. what kind of remix the song is) -* `` - The person who made the map -* `` - The LevelID of the song (not the beatsaver id) -* `` - The beats per minute the song uses -

-* `` - The Rank you got (`B`, `A`, `S`, `SS`, etc.) -* `` - The accuracy you achieved (e.g. `91.31`, `FAILED`, `QUIT`) -* `` - The times you missed notes (`1.4.0+`) -* `` - The best combo you achieved in that play -* `` - Your Score, with mod multipliers enabled -* `` - Your Score, without mod multipliers enabled - -(If you potentially need more placeholders, create an issue and i'll see what i can do.) +3. Run it once, it'll ask you to input your settings. Do so. +4. Profit ## BeatRecorder not connecting? -**AVG Antivirus is known to cause all kinds of issues** with connection-related things. (Credit to `Arro#6969` to help me discover this issue! <3) +**AVG Antivirus is known to cause all kinds of issues** with connection-related things. (Credit to `Arro#6969`. They discoverd and reported this issue.) If you have AVG Antivirus installed, please uninstall it and find a better antivirus solution. If you ruled out your antivirus (through uninstalling it or deactivating it's protection), just create an issue or join my Discord like mentioned before. -## Notable differences between beatsaber-http-status and BSDataPuller - -Any difference that affects the way BeatRecorder works will be documented here. Found more differences? You can create an issue with a detailed description. - -* Accuracy Calculation with BSDataPuller is based in your current progress instead of the whole song -* `` uses the LevelHash instead of the ID - ## Contributing or forking -Before building, you'll need [Xorog.Logger](https://github.com/Fortunevale/Xorog.Logger). +Before building, you'll need [Xorog.Logger](https://github.com/Fortunevale/Xorog.Logger) and [Xorog.UniversalExtensions](https://github.com/Fortunevale/Xorog.UniversalExtensions). -From there on, it should be as easy as cloning the repository and running `dotnet restore`. +From there on, it should be as easy as cloning the repository and running `dotnet restore`. \ No newline at end of file diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..144e63a --- /dev/null +++ b/build.cmd @@ -0,0 +1,3 @@ +rmdir /S /Q BeatRecorder\bin\Release +dotnet publish -p:PublishSingleFile=true -c Release -p:DebugType=None -p:DebugSymbols=false +explorer BeatRecorder\bin\Release\net6.0-windows\win-x64\publish \ No newline at end of file