├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── Build.yml │ └── PR_Build.yml ├── MultiplayerCore ├── Icons │ └── MpFolder.png ├── UI │ ├── RequirementsButton.bsml │ ├── MpDifficultySelector.bsml │ ├── LoadingIndicator.bsml │ ├── MpPerPlayerToggles.bsml │ ├── RequirementsUI.bsml │ ├── ColorsUI.bsml │ ├── MpLoadingIndicator.cs │ └── MpColorsUI.cs ├── Players │ ├── Packets │ │ ├── GetMpPerPlayerPacket.cs │ │ └── MpPerPlayerPacket.cs │ ├── MpPlayerData.cs │ └── MpPlayerManager.cs ├── Utilities.cs ├── Networking │ ├── Abstractions │ │ └── MpPacket.cs │ ├── Attributes │ │ └── PacketIDAttribute.cs │ └── MpPacketSerializer.cs ├── Beatmaps │ ├── NoInfoBeatmapLevel.cs │ ├── Abstractions │ │ └── MpBeatmap.cs │ ├── BeatSaverPreviewMediaData.cs │ ├── NetworkBeatmapLevel.cs │ ├── LocalBeatmapLevel.cs │ ├── BeatSaverBeatmapLevel.cs │ ├── Providers │ │ └── MpBeatmapLevelProvider.cs │ ├── Serializable │ │ └── DifficultyColors.cs │ └── Packets │ │ └── MpBeatmapPacket.cs ├── ScoreSyncState │ ├── MpScoreSyncStatePacket.cs │ └── MpScoreSyncStateManager.cs ├── NodePoseSyncState │ ├── MpNodePoseSyncStatePacket.cs │ └── MpNodePoseSyncStateManager.cs ├── Directory.Build.props ├── manifest.json ├── Patches │ ├── OverridePatches │ │ ├── PlayersDataModelOverride.cs │ │ └── MultiplayerLevelLoaderOverride.cs │ ├── MultiplayerLevelFinishedControllerPatch.cs │ ├── PlatformAuthenticationTokenProviderPatch.cs │ ├── MultiplayerStatusModelPatch.cs │ ├── MultiplayerMenuBinderPatch.cs │ ├── DataModelBinderPatch.cs │ ├── MainSystemBinderPatch.cs │ ├── OutroAnimationPatches.cs │ ├── GraphAPIClientPatch.cs │ ├── LoggingPatch.cs │ ├── IntroAnimationPatches.cs │ ├── NoLevelSpectatorPatch.cs │ └── MultiplayerUnavailableReasonPatches.cs ├── Installers │ ├── MpMenuInstaller.cs │ └── MpAppInstaller.cs ├── Objects │ ├── BGNetDebugLogger.cs │ ├── MpLevelLoader.cs │ ├── MpLevelDownloader.cs │ └── MpPlayersDataModel.cs ├── Patchers │ ├── ModeSelectionPatcher.cs │ ├── UpdateMapPatcher.cs │ ├── CustomLevelsPatcher.cs │ ├── BeatmapSelectionViewPatcher.cs │ ├── GameServerPlayerTableCellPatcher.cs │ ├── PlayerCountPatcher.cs │ └── NetworkConfigPatcher.cs ├── Plugin.cs ├── Helpers │ ├── EventAwaiter.cs │ └── SongCoreConfig.cs ├── Repositories │ └── MpStatusRepository.cs ├── Models │ └── MpStatusData.cs └── Directory.Build.targets ├── MultiplayerCore.sln.DotSettings ├── LICENSE ├── MultiplayerCore.sln ├── README.md └── .gitignore /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: goobwabber 2 | ko_fi: goobwabber 3 | -------------------------------------------------------------------------------- /MultiplayerCore/Icons/MpFolder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Goobwabber/MultiplayerCore/HEAD/MultiplayerCore/Icons/MpFolder.png -------------------------------------------------------------------------------- /MultiplayerCore/UI/RequirementsButton.bsml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /MultiplayerCore/UI/MpDifficultySelector.bsml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /MultiplayerCore/UI/LoadingIndicator.bsml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /MultiplayerCore.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True 3 | True -------------------------------------------------------------------------------- /MultiplayerCore/Players/Packets/GetMpPerPlayerPacket.cs: -------------------------------------------------------------------------------- 1 | using LiteNetLib.Utils; 2 | using MultiplayerCore.Networking.Abstractions; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using MultiplayerCore.Networking.Attributes; 9 | 10 | namespace MultiplayerCore.Players.Packets 11 | { 12 | internal class GetMpPerPlayerPacket : MpPacket 13 | { 14 | public override void Deserialize(NetDataReader reader) { } 15 | 16 | public override void Serialize(NetDataWriter writer) { } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /MultiplayerCore/Utilities.cs: -------------------------------------------------------------------------------- 1 | namespace MultiplayerCore 2 | { 3 | public static class Utilities 4 | { 5 | public static string? HashForLevelID(string? levelId) 6 | { 7 | if (string.IsNullOrWhiteSpace(levelId)) 8 | return null!; 9 | string[] ary = levelId!.Split('_', ' '); 10 | string hash = null!; 11 | if (ary.Length > 2) 12 | hash = ary[2]; 13 | if ((hash?.Length ?? 0) == 40) 14 | return hash!; 15 | return null; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /MultiplayerCore/UI/MpPerPlayerToggles.bsml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /MultiplayerCore/Networking/Abstractions/MpPacket.cs: -------------------------------------------------------------------------------- 1 | using LiteNetLib.Utils; 2 | 3 | namespace MultiplayerCore.Networking.Abstractions 4 | { 5 | public abstract class MpPacket : INetSerializable 6 | { 7 | /// 8 | /// Serializes the packet and puts data into a . 9 | /// 10 | /// Writer to put data into 11 | public abstract void Serialize(NetDataWriter writer); 12 | 13 | /// 14 | /// Deserializes packet data from a . 15 | /// 16 | /// Reader to get data from 17 | public abstract void Deserialize(NetDataReader reader); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MultiplayerCore/Networking/Attributes/PacketIDAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace MultiplayerCore.Networking.Attributes 4 | { 5 | /// 6 | /// An attribute for defining a packet ID for use. Without this, the class name will be used as the packet ID. 7 | /// 8 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] 9 | public class PacketIDAttribute : Attribute 10 | { 11 | internal string ID { get; } 12 | 13 | /// 14 | /// The constructor for the PacketID 15 | /// 16 | /// The id to use to identify this packet. 17 | public PacketIDAttribute(string id) 18 | { 19 | ID = id; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/NoInfoBeatmapLevel.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Beatmaps.Abstractions; 2 | 3 | namespace MultiplayerCore.Beatmaps 4 | { 5 | /// 6 | /// Beatmap level data placeholder, used when no information is available. 7 | /// 8 | public class NoInfoBeatmapLevel : MpBeatmap 9 | { 10 | public override string LevelHash { get; protected set; } 11 | public override string SongName => string.Empty; 12 | public override string SongSubName => string.Empty; 13 | public override string SongAuthorName => string.Empty; 14 | public override string LevelAuthorName => string.Empty; 15 | 16 | public NoInfoBeatmapLevel(string hash) 17 | { 18 | LevelHash = hash; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /MultiplayerCore/ScoreSyncState/MpScoreSyncStatePacket.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Networking.Abstractions; 2 | using LiteNetLib.Utils; 3 | 4 | namespace MultiplayerCore.NodePoseSyncState 5 | { 6 | internal class MpScoreSyncStatePacket : MpPacket 7 | { 8 | public long deltaUpdateFrequency = 100L; 9 | public long fullStateUpdateFrequency = 500L; 10 | public override void Serialize(NetDataWriter writer) 11 | { 12 | writer.PutVarLong(deltaUpdateFrequency); 13 | writer.PutVarLong(fullStateUpdateFrequency); 14 | } 15 | 16 | public override void Deserialize(NetDataReader reader) 17 | { 18 | deltaUpdateFrequency = reader.GetVarLong(); 19 | fullStateUpdateFrequency = reader.GetVarLong(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MultiplayerCore/NodePoseSyncState/MpNodePoseSyncStatePacket.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Networking.Abstractions; 2 | using LiteNetLib.Utils; 3 | 4 | namespace MultiplayerCore.NodePoseSyncState 5 | { 6 | internal class MpNodePoseSyncStatePacket : MpPacket 7 | { 8 | public long deltaUpdateFrequency = 10L; 9 | public long fullStateUpdateFrequency = 100L; 10 | public override void Serialize(NetDataWriter writer) 11 | { 12 | writer.PutVarLong(deltaUpdateFrequency); 13 | writer.PutVarLong(fullStateUpdateFrequency); 14 | } 15 | 16 | public override void Deserialize(NetDataReader reader) 17 | { 18 | deltaUpdateFrequency = reader.GetVarLong(); 19 | fullStateUpdateFrequency = reader.GetVarLong(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MultiplayerCore/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | true 7 | true 8 | 9 | 10 | 11 | 12 | 13 | false 14 | true 15 | true 16 | 17 | -------------------------------------------------------------------------------- /MultiplayerCore/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/bsmg/BSIPA-MetadataFileSchema/master/Schema.json", 3 | "id": "MultiplayerCore", 4 | "name": "MultiplayerCore", 5 | "author": "Goobwabber", 6 | "version": "1.6.3", 7 | "description": "Adds custom songs to Beat Saber Multiplayer.", 8 | "gameVersion": "1.40.0", 9 | "dependsOn": { 10 | "BSIPA": "^4.3.3", 11 | "SongCore": "^3.15.2", 12 | "BeatSaverSharp": "^3.4.5", 13 | "SiraUtil": "^3.1.12", 14 | "BeatSaberMarkupLanguage": "^1.12.0", 15 | "System.IO.Compression": "^4.6.57", 16 | "System.IO.Compression.FileSystem": "^4.7.3056" 17 | }, 18 | "links": { 19 | "project-home": "https://github.com/Goobwabber/MultiplayerCore", 20 | "donate": "https://github.com/Goobwabber/MultiplayerCore#donate" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/OverridePatches/PlayersDataModelOverride.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Objects; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace MultiplayerCore.Patches.OverridePatches 10 | { 11 | [HarmonyPatch(typeof(LobbyPlayersDataModel))] 12 | internal class PlayersDataModelOverride 13 | { 14 | 15 | [HarmonyPrefix] 16 | [HarmonyPatch(nameof(LobbyPlayersDataModel.SetLocalPlayerBeatmapLevel))] 17 | private static void SetLocalPlayerBeatmapLevel_override(LobbyPlayersDataModel __instance, in BeatmapKey beatmapKey) 18 | { 19 | Plugin.Logger.Debug("Called LobbyPlayersDataModel.SetLocalPlayerBeatmapLevel Override Patch"); 20 | ((MpPlayersDataModel)__instance).SetLocalPlayerBeatmapLevel_override(beatmapKey); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MultiplayerCore/Players/Packets/MpPerPlayerPacket.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using LiteNetLib.Utils; 7 | using MultiplayerCore.Networking.Abstractions; 8 | using MultiplayerCore.Networking.Attributes; 9 | 10 | namespace MultiplayerCore.Players.Packets 11 | { 12 | internal class MpPerPlayerPacket : MpPacket 13 | { 14 | public bool PPDEnabled; 15 | public bool PPMEnabled; 16 | 17 | public override void Deserialize(NetDataReader reader) 18 | { 19 | PPDEnabled = reader.GetBool(); 20 | PPMEnabled = reader.GetBool(); 21 | } 22 | 23 | public override void Serialize(NetDataWriter writer) 24 | { 25 | writer.Put(PPDEnabled); 26 | writer.Put(PPMEnabled); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MultiplayerCore/UI/RequirementsUI.bsml: -------------------------------------------------------------------------------- 1 | 12 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /MultiplayerCore/Installers/MpMenuInstaller.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Patchers; 2 | using MultiplayerCore.UI; 3 | using Zenject; 4 | 5 | namespace MultiplayerCore.Installers 6 | { 7 | internal class MpMenuInstaller : MonoInstaller 8 | { 9 | public override void InstallBindings() 10 | { 11 | Container.BindInterfacesAndSelfTo().AsSingle(); 12 | Container.BindInterfacesAndSelfTo().AsSingle(); 13 | Container.BindInterfacesAndSelfTo().AsSingle(); 14 | Container.BindInterfacesAndSelfTo().AsSingle(); 15 | Container.BindInterfacesAndSelfTo().AsSingle(); 16 | Container.BindInterfacesAndSelfTo().AsSingle(); 17 | 18 | // Inject sira stuff that didn't get injected on appinit 19 | Container.Inject(Container.Resolve()); 20 | } 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MultiplayerCore/Objects/BGNetDebugLogger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using BGNet.Logging; 3 | using SiraUtil.Logging; 4 | 5 | namespace MultiplayerCore.Objects 6 | { 7 | // TODO: Turn into patches, this has never worked 8 | internal class BGNetDebugLogger : Debug.ILogger 9 | { 10 | private readonly SiraLog _logger; 11 | 12 | internal BGNetDebugLogger( 13 | SiraLog logger) 14 | { 15 | _logger = logger; 16 | Debug.AddLogger(this); 17 | } 18 | 19 | public void LogError(string message) 20 | => _logger.Error(message); 21 | 22 | public void LogException(Exception exception, string? message = null) 23 | { 24 | if (message != null) 25 | _logger.Error(message); 26 | _logger.Error(exception); 27 | } 28 | 29 | public void LogInfo(string message) 30 | => _logger.Info(message); 31 | 32 | public void LogWarning(string message) 33 | => _logger.Warn(message); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /MultiplayerCore/UI/ColorsUI.bsml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Goobwabber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MultiplayerCore/Patches/MultiplayerLevelFinishedControllerPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace MultiplayerCore.Patches 9 | { 10 | [HarmonyPatch] 11 | internal class MultiplayerLevelFinishedControllerPatch 12 | { 13 | [HarmonyPrefix] 14 | [HarmonyPatch(typeof(MultiplayerLevelFinishedController), nameof(MultiplayerLevelFinishedController.HandleRpcLevelFinished))] 15 | static bool HandleRpcLevelFinished(MultiplayerLevelFinishedController __instance, string userId, MultiplayerLevelCompletionResults results) 16 | { 17 | // Possibly get notesCount from BeatSaver or by parsing the beatmapdata ourselves 18 | // Skip score validation if notesCount is 0, since custom songs always have notesCount 0 in BeatmapBasicData 19 | if (__instance._beatmapBasicData.notesCount <= 0 && results.hasAnyResults) 20 | { 21 | Plugin.Logger.Info($"BeatmapData noteCount is 0, skipping validation"); 22 | __instance._otherPlayersCompletionResults[userId] = results; 23 | return false; 24 | } 25 | return true; 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /MultiplayerCore.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30717.126 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerCore", "MultiplayerCore\MultiplayerCore.csproj", "{262A86B8-040A-46E6-948C-66A3CAA605AD}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {262A86B8-040A-46E6-948C-66A3CAA605AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {262A86B8-040A-46E6-948C-66A3CAA605AD}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {262A86B8-040A-46E6-948C-66A3CAA605AD}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {262A86B8-040A-46E6-948C-66A3CAA605AD}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {8657A840-3A69-4C11-81C7-FA3CDA517FED} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: "[BUG] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | **MultiplayerCore Version and Download Source** 10 | 11 | 12 | **Your Platform** 13 | 14 | 15 | **Describe the bug** 16 | 17 | 18 | **To Reproduce** 19 | 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | 27 | 28 | **Log** 29 | 31 | 32 | **Screenshots/Video** 33 | 34 | 35 | **Additional context** 36 | 37 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/PlatformAuthenticationTokenProviderPatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using HarmonyLib; 7 | 8 | namespace MultiplayerCore.Patches 9 | { 10 | [HarmonyPatch] 11 | internal class PlatformAuthenticationTokenProviderPatch 12 | { 13 | public static readonly string DummyAuth = "77686f5f69735f72656d5f3f"; 14 | 15 | [HarmonyPostfix] 16 | [HarmonyPatch(typeof(PlatformAuthenticationTokenProvider), nameof(PlatformAuthenticationTokenProvider.GetAuthenticationToken))] 17 | private static void GetAuthenticationToken(PlatformAuthenticationTokenProvider __instance, ref Task __result) 18 | { 19 | __result = __result.ContinueWith(task => 20 | { 21 | AuthenticationToken result; 22 | if (task.IsFaulted || string.IsNullOrWhiteSpace((result = task.Result).sessionToken)) 23 | { 24 | Plugin.Logger.Error("An error occurred while attempting to get the auth token: " + task.Exception); 25 | Plugin.Logger.Warn("Failed to get auth token, returning custom authentication token!"); 26 | return new AuthenticationToken(__instance.platform, __instance.hashedUserId, __instance.userName, DummyAuth); 27 | } 28 | Plugin.Logger.Debug("Successfully got auth token!"); 29 | return result; 30 | }); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/MultiplayerStatusModelPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Models; 3 | using Newtonsoft.Json; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Reflection; 7 | using System.Reflection.Emit; 8 | using UnityEngine; 9 | 10 | namespace MultiplayerCore.Patches 11 | { 12 | [HarmonyPatch] 13 | internal class MultiplayerStatusModelPatch 14 | { 15 | static MethodBase TargetMethod() => 16 | AccessTools.FirstInner(typeof(MultiplayerStatusModel), t => t.Name.StartsWith(" JsonUtility.FromJson(null!)); 19 | private static readonly MethodInfo _deserializeObjectAttacher = SymbolExtensions.GetMethodInfo(() => DeserializeObjectAttacher(null!)); 20 | 21 | private static IEnumerable Transpiler(IEnumerable instructions) => 22 | new CodeMatcher(instructions) 23 | .MatchForward(false, new CodeMatch(i => i.opcode == OpCodes.Call && i.Calls(_deserializeObjectMethod))) 24 | .Set(OpCodes.Call, _deserializeObjectAttacher) 25 | .InstructionEnumeration(); 26 | 27 | private static object DeserializeObjectAttacher(string value) 28 | => JsonConvert.DeserializeObject(value)!; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /MultiplayerCore/Players/MpPlayerData.cs: -------------------------------------------------------------------------------- 1 | using Hive.Versioning; 2 | using LiteNetLib.Utils; 3 | using MultiplayerCore.Networking.Abstractions; 4 | using IPA.Utilities; 5 | 6 | namespace MultiplayerCore.Players 7 | { 8 | public class MpPlayerData : MpPacket 9 | { 10 | /// 11 | /// Platform User ID from 12 | /// 13 | public string PlatformId { get; set; } = string.Empty; 14 | 15 | /// 16 | /// Platform from 17 | /// 18 | public Platform Platform { get; set; } 19 | 20 | /// 21 | /// Version 22 | /// 23 | public Version GameVersion { get; set; } = Version.Parse(UnityGame.GameVersion.ToString().Split('_')[0]); 24 | 25 | public override void Serialize(NetDataWriter writer) 26 | { 27 | writer.Put(PlatformId); 28 | writer.Put((int)Platform); 29 | writer.Put(GameVersion.ToString()); 30 | } 31 | 32 | public override void Deserialize(NetDataReader reader) 33 | { 34 | PlatformId = reader.GetString(); 35 | Platform = (Platform)reader.GetInt(); 36 | GameVersion = Version.Parse(reader.GetString()); 37 | } 38 | } 39 | 40 | public enum Platform 41 | { 42 | Unknown = 0, 43 | Steam = 1, 44 | OculusPC = 2, 45 | OculusQuest = 3, 46 | PS4 = 4, 47 | PS5 = 5, 48 | Pico = 6 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/Build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'MultiplayerCore.sln' 8 | - 'MultiplayerCore/**' 9 | - '.github/workflows/Build.yml' 10 | 11 | jobs: 12 | Build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup dotnet 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | # - name: Fetch SIRA References 21 | # uses: ProjectSIRA/download-sira-stripped@1.0.0 22 | # with: 23 | # manifest: ./MultiplayerCore/manifest.json 24 | # sira-server-code: ${{ secrets.SIRA_SERVER_CODE }} 25 | - name: Initialize modding environment 26 | uses: beat-forge/init-beatsaber@v1 27 | with: 28 | token: ${{ github.token }} 29 | repo: beat-forge/beatsaber-stripped 30 | manifest: ./MultiplayerCore/manifest.json 31 | - name: Fetch Mod References 32 | uses: Goobwabber/download-beatmods-deps@1.3 33 | with: 34 | manifest: ./MultiplayerCore/manifest.json 35 | - name: Build 36 | id: Build 37 | # env: 38 | # FrameworkPathOverride: /usr/lib/mono/4.8-api 39 | run: dotnet build --configuration Release 40 | - name: GitStatus 41 | run: git status 42 | - name: Echo Filename 43 | run: echo $BUILDTEXT \($ASSEMBLYNAME\) 44 | env: 45 | BUILDTEXT: Filename=${{ steps.Build.outputs.filename }} 46 | ASSEMBLYNAME: AssemblyName=${{ steps.Build.outputs.assemblyname }} 47 | - name: Upload Artifact 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: ${{ steps.Build.outputs.filename }} 51 | path: ${{ steps.Build.outputs.artifactpath }} 52 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/Abstractions/MpBeatmap.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MultiplayerCore.Beatmaps.Serializable; 5 | using UnityEngine; 6 | using static SongCore.Data.SongData; 7 | 8 | namespace MultiplayerCore.Beatmaps.Abstractions 9 | { 10 | /// 11 | /// Base class for Beatmap data that can be used in multiplayer. 12 | /// 13 | public abstract class MpBeatmap 14 | { 15 | /// 16 | /// The hash of the level. Should be the same on all clients. 17 | /// 18 | public abstract string LevelHash { get; protected set; } 19 | /// 20 | /// The local ID of the level. Can vary between clients. 21 | /// 22 | public string LevelID => $"custom_level_{LevelHash}"; 23 | public abstract string SongName { get; } 24 | public abstract string SongSubName { get; } 25 | public abstract string SongAuthorName { get; } 26 | public abstract string LevelAuthorName { get; } 27 | public virtual float BeatsPerMinute { get; protected set; } 28 | public virtual float SongDuration { get; protected set; } 29 | public virtual Dictionary> Requirements { get; protected set; } = new(); 30 | public virtual Dictionary> DifficultyColors { get; protected set; } = new(); 31 | public virtual Contributor[]? Contributors { get; protected set; } = null!; 32 | 33 | public virtual Task TryGetCoverSpriteAsync(CancellationToken cancellationToken) 34 | => Task.FromResult(null!); 35 | } 36 | } -------------------------------------------------------------------------------- /.github/workflows/PR_Build.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Build 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - 'MultiplayerCore.sln' 8 | - 'MultiplayerCore/**' 9 | - '.github/workflows/PR_Build.yml' 10 | 11 | jobs: 12 | Build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup dotnet 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | # - name: Fetch SIRA References 21 | # uses: ProjectSIRA/download-sira-stripped@1.0.0 22 | # with: 23 | # manifest: ./MultiplayerCore/manifest.json 24 | # sira-server-code: ${{ secrets.SIRA_SERVER_CODE }} 25 | - name: Initialize modding environment 26 | uses: beat-forge/init-beatsaber@v1 27 | with: 28 | token: ${{ github.token }} 29 | repo: beat-forge/beatsaber-stripped 30 | manifest: ./MultiplayerCore/manifest.json 31 | - name: Fetch Mod References 32 | uses: Goobwabber/download-beatmods-deps@1.3 33 | with: 34 | manifest: ./MultiplayerCore/manifest.json 35 | - name: Build 36 | id: Build 37 | env: 38 | FrameworkPathOverride: /usr/lib/mono/4.8-api 39 | run: dotnet build --configuration Release 40 | - name: GitStatus 41 | run: git status 42 | - name: Echo Filename 43 | run: echo $BUILDTEXT \($ASSEMBLYNAME\) 44 | env: 45 | BUILDTEXT: Filename=${{ steps.Build.outputs.filename }} 46 | ASSEMBLYNAME: AssemblyName=${{ steps.Build.outputs.assemblyname }} 47 | - name: Upload Artifact 48 | uses: actions/upload-artifact@v3 49 | with: 50 | name: ${{ steps.Build.outputs.filename }} 51 | path: ${{ steps.Build.outputs.artifactpath }} 52 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/MultiplayerMenuBinderPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Objects; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Reflection.Emit; 8 | using Zenject; 9 | 10 | namespace MultiplayerCore.Patches 11 | { 12 | [HarmonyPatch(typeof(MultiplayerMenuInstaller), nameof(MultiplayerMenuInstaller.InstallBindings), MethodType.Normal)] 13 | internal class MultiplayerMenuBinderPatch 14 | { 15 | private static readonly MethodInfo _rootMethod = typeof(DiContainer).GetMethod(nameof(DiContainer.BindInterfacesAndSelfTo), Array.Empty()); 16 | 17 | private static readonly MethodInfo _levelLoaderAttacher = SymbolExtensions.GetMethodInfo(() => LevelLoaderAttacher(null!)); 18 | private static readonly MethodInfo _levelLoaderMethod = _rootMethod.MakeGenericMethod(new Type[] { typeof(MultiplayerLevelLoader) }); 19 | 20 | static IEnumerable Transpiler(IEnumerable instructions) 21 | { 22 | var codes = instructions.ToList(); 23 | for (int i = 0; i < codes.Count; i++) 24 | { 25 | if (codes[i].opcode == OpCodes.Callvirt && codes[i].Calls(_levelLoaderMethod)) 26 | { 27 | CodeInstruction newCode = new CodeInstruction(OpCodes.Callvirt, _levelLoaderAttacher); 28 | codes[i] = newCode; 29 | } 30 | } 31 | 32 | return codes.AsEnumerable(); 33 | } 34 | 35 | private static FromBinderNonGeneric LevelLoaderAttacher(DiContainer contract) 36 | { 37 | return contract.Bind(typeof(MultiplayerLevelLoader), typeof(MpLevelLoader), typeof(ITickable)).To(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /MultiplayerCore/Installers/MpAppInstaller.cs: -------------------------------------------------------------------------------- 1 | using BeatSaverSharp; 2 | using MultiplayerCore.Beatmaps.Providers; 3 | using MultiplayerCore.Networking; 4 | using MultiplayerCore.NodePoseSyncState; 5 | using MultiplayerCore.Objects; 6 | using MultiplayerCore.Patchers; 7 | using MultiplayerCore.Players; 8 | using MultiplayerCore.Repositories; 9 | using SiraUtil.Zenject; 10 | using Zenject; 11 | 12 | namespace MultiplayerCore.Installers 13 | { 14 | internal class MpAppInstaller : Installer 15 | { 16 | private readonly BeatSaver _beatsaver; 17 | 18 | public MpAppInstaller( 19 | BeatSaver beatsaver) 20 | { 21 | _beatsaver = beatsaver; 22 | } 23 | 24 | public override void InstallBindings() 25 | { 26 | Container.BindInstance(new UBinder(_beatsaver)).AsSingle(); 27 | Container.BindInterfacesAndSelfTo().AsSingle(); 28 | Container.BindInterfacesAndSelfTo().AsSingle(); 29 | Container.BindInterfacesAndSelfTo().AsSingle(); 30 | Container.BindInterfacesAndSelfTo().AsSingle(); 31 | Container.Bind().ToSelf().AsSingle(); 32 | Container.Bind().ToSelf().AsSingle(); 33 | Container.BindInterfacesAndSelfTo().AsSingle(); 34 | Container.BindInterfacesAndSelfTo().AsSingle(); 35 | Container.BindInterfacesAndSelfTo().AsSingle(); 36 | Container.BindInterfacesAndSelfTo().AsSingle(); 37 | Container.Bind().ToSelf().AsSingle(); 38 | Container.BindInterfacesAndSelfTo().AsSingle(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/DataModelBinderPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Objects; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Reflection.Emit; 8 | using Zenject; 9 | 10 | namespace MultiplayerCore.Patches 11 | { 12 | [HarmonyPatch(typeof(LobbyDataModelInstaller), nameof(LobbyDataModelInstaller.InstallBindings))] 13 | internal class DataModelBinderPatch 14 | { 15 | private static readonly MethodInfo _rootMethod = typeof(ConcreteBinderNonGeneric).GetMethod(nameof(ConcreteBinderNonGeneric.To), Array.Empty()); 16 | 17 | private static readonly MethodInfo _playersDataModelAttacher = SymbolExtensions.GetMethodInfo(() => PlayersDataModelAttacher(null!)); 18 | private static readonly MethodInfo _playersDataModelMethod = _rootMethod.MakeGenericMethod(new Type[] { typeof(LobbyPlayersDataModel) }); 19 | 20 | static IEnumerable Transpiler(IEnumerable instructions) 21 | { 22 | var codes = instructions.ToList(); 23 | for (int i = 0; i < codes.Count; i++) 24 | { 25 | if (codes[i].opcode == OpCodes.Callvirt) 26 | { 27 | if (codes[i].Calls(_playersDataModelMethod)) 28 | { 29 | CodeInstruction newCode = new CodeInstruction(OpCodes.Callvirt, _playersDataModelAttacher); 30 | codes[i] = newCode; 31 | } 32 | } 33 | } 34 | 35 | return codes.AsEnumerable(); 36 | } 37 | 38 | private static FromBinderNonGeneric PlayersDataModelAttacher(ConcreteBinderNonGeneric contract) 39 | { 40 | return contract.To(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/ModeSelectionPatcher.cs: -------------------------------------------------------------------------------- 1 | using SiraUtil.Affinity; 2 | 3 | namespace MultiplayerCore.Patchers 4 | { 5 | internal class ModeSelectionPatcher : IAffinity 6 | { 7 | private string _lastStatusUrl = string.Empty; 8 | 9 | private readonly INetworkConfig _networkConfig; 10 | 11 | internal ModeSelectionPatcher( 12 | INetworkConfig networkConfig) 13 | { 14 | _networkConfig = networkConfig; 15 | } 16 | 17 | [AffinityPrefix] 18 | [AffinityPatch(typeof(MultiplayerStatusModel), nameof(MultiplayerStatusModel.IsAvailabilityTaskValid))] 19 | private bool IsAvailabilityTaskValid(ref bool __result) 20 | { 21 | if (_networkConfig.multiplayerStatusUrl == _lastStatusUrl) 22 | return true; 23 | _lastStatusUrl = _networkConfig.multiplayerStatusUrl; 24 | __result = false; 25 | return false; 26 | } 27 | 28 | [AffinityPrefix] 29 | [AffinityPatch(typeof(QuickPlaySetupModel), nameof(QuickPlaySetupModel.IsQuickPlaySetupTaskValid))] 30 | private bool IsQuickplaySetupTaskValid(ref bool __result) 31 | { 32 | if (_networkConfig.multiplayerStatusUrl == _lastStatusUrl) 33 | return true; 34 | _lastStatusUrl = _networkConfig.multiplayerStatusUrl; 35 | __result = false; 36 | return false; 37 | } 38 | 39 | // If there is no availability data, assume that it's fine 40 | [AffinityPrefix] 41 | [AffinityPatch(typeof(MultiplayerUnavailableReasonMethods), nameof(MultiplayerUnavailableReasonMethods.TryGetMultiplayerUnavailableReason))] 42 | private bool GetMultiplayerUnavailableReason(MultiplayerStatusData? data, ref bool __result) 43 | { 44 | if (data != null) 45 | return true; 46 | __result = false; 47 | return false; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/MainSystemBinderPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Objects; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Reflection.Emit; 7 | using Zenject; 8 | 9 | namespace MultiplayerCore.Patches 10 | { 11 | [HarmonyPatch(typeof(MainSystemInit), nameof(MainSystemInit.InstallBindings), MethodType.Normal)] 12 | internal class MainSystemBinderPatch 13 | { 14 | private static readonly MethodInfo _rootMethod = typeof(FromBinder).GetMethod(nameof(FromBinder.FromComponentInNewPrefab), new[] { typeof(UnityEngine.Object) }); 15 | 16 | private static readonly MethodInfo _entitlementCheckerAttacher = SymbolExtensions.GetMethodInfo(() => EntitlementCheckerAttacher(null!, null!)); 17 | private static readonly FieldInfo _entitlementCheckerPrefab = typeof(MainSystemInit).GetField("_networkPlayerEntitlementCheckerPrefab", BindingFlags.NonPublic | BindingFlags.Instance); 18 | 19 | static IEnumerable Transpiler(IEnumerable instructions) 20 | { 21 | var codes = instructions.ToList(); 22 | for (int i = 0; i < codes.Count; i++) 23 | { 24 | if (codes[i].opcode == OpCodes.Ldfld && codes[i].OperandIs(_entitlementCheckerPrefab)) 25 | { 26 | if (codes[i + 1].opcode == OpCodes.Callvirt && codes[i + 1].Calls(_rootMethod)) 27 | { 28 | CodeInstruction newCode = new CodeInstruction(OpCodes.Callvirt, _entitlementCheckerAttacher); 29 | codes[i + 1] = newCode; 30 | } 31 | } 32 | } 33 | 34 | return codes.AsEnumerable(); 35 | } 36 | 37 | private static ScopeConcreteIdArgConditionCopyNonLazyBinder EntitlementCheckerAttacher(ConcreteIdBinderGeneric contract, UnityEngine.Object prefab) 38 | { 39 | return contract.To().FromNewComponentOnRoot(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /MultiplayerCore/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using BeatSaverSharp; 4 | using HarmonyLib; 5 | using IPA; 6 | using IPA.Loader; 7 | using MultiplayerCore.Installers; 8 | using SiraUtil.Zenject; 9 | using UnityEngine; 10 | using IPALogger = IPA.Logging.Logger; 11 | 12 | namespace MultiplayerCore 13 | { 14 | [Plugin(RuntimeOptions.DynamicInit)] 15 | internal class Plugin 16 | { 17 | public const string ID = "com.goobwabber.multiplayercore"; 18 | public const string CustomLevelsPath = "CustomMultiplayerLevels"; 19 | 20 | internal static IPALogger Logger = null!; 21 | internal static BeatSaver _beatsaver = null!; 22 | 23 | private readonly Harmony _harmony; 24 | private readonly PluginMetadata _metadata; 25 | 26 | [Init] 27 | public Plugin(IPALogger logger, PluginMetadata pluginMetadata, Zenjector zenjector) 28 | { 29 | _harmony = new Harmony(ID); 30 | _metadata = pluginMetadata; 31 | _beatsaver = new BeatSaver(ID, new Version(_metadata.HVersion.ToString())); 32 | Logger = logger; 33 | 34 | zenjector.UseMetadataBinder(); 35 | zenjector.UseLogger(logger); 36 | zenjector.UseHttpService(); 37 | zenjector.UseSiraSync(SiraUtil.Web.SiraSync.SiraSyncServiceType.GitHub, "Goobwabber", "MultiplayerCore"); 38 | zenjector.Install(Location.App, _beatsaver); 39 | zenjector.Install(Location.Menu); 40 | } 41 | 42 | [OnEnable] 43 | public void OnEnable() 44 | { 45 | SongCore.Collections.AddSeparateSongFolder( 46 | "Multiplayer", 47 | Path.Combine(Application.dataPath, CustomLevelsPath), 48 | SongCore.Data.FolderLevelPack.NewPack, 49 | SongCore.Utilities.Utils.LoadSpriteFromResources("MultiplayerCore.Icons.MpFolder.png") 50 | ); 51 | _harmony.PatchAll(_metadata.Assembly); 52 | } 53 | 54 | [OnDisable] 55 | public void OnDisable() 56 | { 57 | _harmony.UnpatchSelf(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/OutroAnimationPatches.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Reflection.Emit; 6 | using UnityEngine; 7 | 8 | namespace MultiplayerCore.Patches 9 | { 10 | [HarmonyPatch] 11 | internal class OutroAnimationPatches 12 | { 13 | [HarmonyPrefix] 14 | [HarmonyPatch(typeof(MultiplayerOutroAnimationController), nameof(MultiplayerOutroAnimationController.BindRingsAndAudio))] 15 | private static void BindRingsAndAudio(ref GameObject[] rings) 16 | { 17 | rings = rings.Take(5).ToArray(); 18 | } 19 | 20 | private static readonly MethodInfo _getActivePlayersMethod = AccessTools.PropertyGetter(typeof(MultiplayerPlayersManager), nameof(MultiplayerPlayersManager.allActiveAtGameStartPlayers)); 21 | 22 | [HarmonyTranspiler] 23 | [HarmonyPatch(typeof(MultiplayerOutroAnimationController), nameof(MultiplayerOutroAnimationController.BindOutroTimeline))] 24 | private static IEnumerable PlayIntroPlayerCount(IEnumerable instructions) 25 | { 26 | var codes = instructions.ToList(); 27 | for (int i = 0; i < codes.Count; i++) 28 | { 29 | if (codes[i].Calls(_getActivePlayersMethod)) 30 | { 31 | codes[i] = new CodeInstruction(OpCodes.Callvirt, SymbolExtensions.GetMethodInfo(() => GetActivePlayersAttacher(null!))); 32 | } 33 | } 34 | return codes.AsEnumerable(); 35 | } 36 | 37 | private static IReadOnlyList GetActivePlayersAttacher(MultiplayerPlayersManager contract) 38 | { 39 | return contract.allActiveAtGameStartPlayers.Take(4).ToList(); 40 | } 41 | 42 | [HarmonyPrefix] 43 | [HarmonyPatch(typeof(MultiplayerResultsPyramidView), nameof(MultiplayerResultsPyramidView.SetupResults))] 44 | private static void SetupResultsPyramid(ref IReadOnlyList resultsData) 45 | { 46 | resultsData = resultsData.Take(5).ToList(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MultiplayerCore/Helpers/EventAwaiter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace MultiplayerCore.Helpers 6 | { 7 | internal class EventAwaiter : IDisposable 8 | { 9 | private TaskCompletionSource _taskCompletion; 10 | private CancellationTokenRegistration TokenRegistration; 11 | private bool disposedValue; 12 | 13 | public Task Task => _taskCompletion.Task; 14 | 15 | public EventAwaiter() 16 | { 17 | _taskCompletion = new TaskCompletionSource(); 18 | } 19 | public EventAwaiter(CancellationToken cancellationToken) 20 | : this() 21 | { 22 | if (cancellationToken.CanBeCanceled) 23 | { 24 | TokenRegistration = cancellationToken.Register(Cancel); 25 | } 26 | } 27 | 28 | public void Cancel() 29 | { 30 | TriggerFinished(false); 31 | TokenRegistration.Dispose(); 32 | } 33 | 34 | protected void TriggerFinished(bool success = true) 35 | { 36 | _taskCompletion.TrySetResult(success); 37 | TokenRegistration.Dispose(); 38 | } 39 | 40 | protected virtual void Dispose(bool disposing) 41 | { 42 | if (!disposedValue) 43 | { 44 | if (disposing) 45 | { 46 | TokenRegistration.Dispose(); 47 | } 48 | disposedValue = true; 49 | } 50 | } 51 | 52 | public void Dispose() 53 | { 54 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 55 | Dispose(disposing: true); 56 | GC.SuppressFinalize(this); 57 | } 58 | } 59 | 60 | internal class EventAwaiter : EventAwaiter 61 | { 62 | public EventAwaiter() 63 | : base() 64 | { } 65 | public EventAwaiter(CancellationToken cancellationToken) 66 | : base(cancellationToken) 67 | { } 68 | 69 | public void OnEvent(T1 _, T2 __) 70 | { 71 | TriggerFinished(true); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/BeatSaverPreviewMediaData.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using UnityEngine; 4 | using BeatSaverSharp; 5 | using BeatSaverSharp.Models; 6 | 7 | namespace MultiplayerCore.Beatmaps 8 | { 9 | public class BeatSaverPreviewMediaData : IPreviewMediaData 10 | { 11 | 12 | public string LevelHash { get; private set; } 13 | public BeatSaver BeatSaverClient { get; private set; } 14 | public Sprite? CoverImagesprite { get; private set; } 15 | 16 | public BeatSaverPreviewMediaData(string levelHash) : this(Plugin._beatsaver, levelHash) {} 17 | 18 | public BeatSaverPreviewMediaData(BeatSaver beatsaver, string levelHash) 19 | { 20 | BeatSaverClient = beatsaver; 21 | LevelHash = levelHash; 22 | } 23 | 24 | private Beatmap? _beatmap = null; 25 | private async Task GetBeatsaverBeatmap() 26 | { 27 | if (_beatmap != null) return _beatmap; 28 | _beatmap = await BeatSaverClient.BeatmapByHash(LevelHash); 29 | return _beatmap; 30 | } 31 | 32 | public async Task GetCoverSpriteAsync() 33 | { 34 | if (CoverImagesprite != null) return CoverImagesprite; 35 | 36 | var bm = await GetBeatsaverBeatmap(); 37 | if (bm == null) return null!; 38 | 39 | byte[]? coverBytes = await bm.LatestVersion.DownloadCoverImage(); 40 | if (coverBytes == null || coverBytes.Length == 0) return null!; 41 | 42 | Texture2D texture = new Texture2D(2, 2); 43 | texture.LoadImage(coverBytes); 44 | CoverImagesprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0, 0), 100.0f); 45 | return CoverImagesprite; 46 | } 47 | 48 | public void UnloadCoverSprite() 49 | { 50 | if (CoverImagesprite == null) return; 51 | Object.Destroy(CoverImagesprite.texture); 52 | Object.Destroy(CoverImagesprite); 53 | CoverImagesprite = null; 54 | } 55 | 56 | public Task GetPreviewAudioClip() 57 | { 58 | // TODO: something with preview url 59 | //var bm = await GetBeatsaverBeatmap(); 60 | // bm.LatestVersion.PreviewURL 61 | return null!; 62 | } 63 | 64 | public void UnloadPreviewAudioClip() {} 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /MultiplayerCore/Repositories/MpStatusRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using MultiplayerCore.Models; 4 | using SiraUtil.Affinity; 5 | using SiraUtil.Logging; 6 | 7 | // ReSharper disable ClassNeverInstantiated.Global 8 | // ReSharper disable InconsistentNaming 9 | 10 | namespace MultiplayerCore.Repositories 11 | { 12 | /// 13 | /// Provides multiplayer status data for all master servers contacted during the game session. 14 | /// 15 | public class MpStatusRepository : IAffinity 16 | { 17 | private readonly INetworkConfig _networkConfig; 18 | private readonly SiraLog _logger; 19 | 20 | private readonly Dictionary _statusByUrl; 21 | private readonly Dictionary _statusByHostname; 22 | 23 | public event Action? statusUpdatedForUrlEvent; 24 | 25 | internal MpStatusRepository(INetworkConfig networkConfig, SiraLog logger) 26 | { 27 | _networkConfig = networkConfig; 28 | _logger = logger; 29 | 30 | _statusByUrl = new(); 31 | _statusByHostname = new(); 32 | } 33 | 34 | #region API 35 | 36 | internal void ReportStatus(MpStatusData statusData) 37 | { 38 | var statusUrl = _networkConfig.multiplayerStatusUrl; 39 | _statusByUrl[statusUrl] = statusData; 40 | RaiseUpdateEvent(statusUrl, statusData); 41 | } 42 | 43 | /// 44 | /// Retrieve the latest multiplayer status data for a given Status URL. 45 | /// 46 | public MpStatusData? GetStatusForUrl(string statusUrl) 47 | => _statusByUrl.TryGetValue(statusUrl, out var statusData) ? statusData : null; 48 | 49 | #endregion 50 | 51 | #region Events 52 | 53 | private void RaiseUpdateEvent(string url, MpStatusData statusData) 54 | { 55 | try 56 | { 57 | statusUpdatedForUrlEvent?.Invoke(url, statusData); 58 | } 59 | catch (Exception ex) 60 | { 61 | _logger.Error("Error in statusUpdatedForUrlEvent handler:"); 62 | _logger.Error(ex); 63 | } 64 | } 65 | 66 | #endregion 67 | 68 | #region Patch 69 | 70 | [AffinityPrefix] 71 | [AffinityPatch(typeof(MultiplayerUnavailableReasonMethods), 72 | nameof(MultiplayerUnavailableReasonMethods.TryGetMultiplayerUnavailableReason))] 73 | private void PrefixTryGetMultiplayerUnavailableReason(MultiplayerStatusData data) 74 | { 75 | // TryGetMultiplayerUnavailableReason is called whenever a server response is parsed 76 | 77 | // MultiplayerStatusModelPatch should have "upgraded" this to an instance of MultiplayerStatusData 78 | if (data is MpStatusData mpStatusData) 79 | ReportStatus(mpStatusData); 80 | } 81 | 82 | #endregion 83 | } 84 | } -------------------------------------------------------------------------------- /MultiplayerCore/Players/MpPlayerManager.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Networking; 2 | using System; 3 | using System.Collections.Concurrent; 4 | using System.Collections.Generic; 5 | using System.Threading; 6 | using Zenject; 7 | 8 | namespace MultiplayerCore.Players 9 | { 10 | public class MpPlayerManager : IInitializable, IDisposable 11 | { 12 | public event Action PlayerConnectedEvent = null!; 13 | 14 | public IReadOnlyDictionary Players => _playerData; 15 | 16 | private UserInfo _localPlayerInfo = null!; 17 | private ConcurrentDictionary _playerData = new(); 18 | 19 | private readonly MpPacketSerializer _packetSerializer; 20 | private readonly IMultiplayerSessionManager _sessionManager; 21 | private readonly IPlatformUserModel _platformUserModel; 22 | 23 | internal MpPlayerManager( 24 | MpPacketSerializer packetSerializer, 25 | IMultiplayerSessionManager sessionManager, 26 | IPlatformUserModel platformUserModel) 27 | { 28 | _packetSerializer = packetSerializer; 29 | _sessionManager = sessionManager; 30 | _platformUserModel = platformUserModel; 31 | } 32 | 33 | public async void Initialize() 34 | { 35 | _sessionManager.SetLocalPlayerState("modded", true); 36 | _packetSerializer.RegisterCallback(HandlePlayerData); 37 | //_sessionManager.playerConnectedEvent += HandlePlayerConnected; 38 | 39 | _localPlayerInfo = await _platformUserModel.GetUserInfo(CancellationToken.None); 40 | } 41 | 42 | public void Dispose() 43 | { 44 | _packetSerializer.UnregisterCallback(); 45 | } 46 | 47 | //private void HandlePlayerConnected(IConnectedPlayer player) 48 | //{ 49 | // if (_localPlayerInfo == null) 50 | // throw new NullReferenceException("local player info was not yet set! make sure it is set before anything else happens!"); 51 | 52 | // _sessionManager.Send(new MpPlayerData 53 | // { 54 | // Platform = _localPlayerInfo.platform switch 55 | // { 56 | // UserInfo.Platform.Oculus => Platform.OculusPC, 57 | // UserInfo.Platform.Steam => Platform.Steam, 58 | // _ => Platform.Unknown 59 | // }, 60 | // PlatformId = _localPlayerInfo.platformUserId 61 | // }); 62 | //} 63 | 64 | private void HandlePlayerData(MpPlayerData packet, IConnectedPlayer player) 65 | { 66 | _playerData[player.userId] = packet; 67 | } 68 | 69 | public bool TryGetPlayer(string userId, out MpPlayerData player) 70 | => _playerData.TryGetValue(userId, out player); 71 | 72 | public MpPlayerData? GetPlayer(string userId) 73 | => _playerData.ContainsKey(userId) ? _playerData[userId] : null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/NetworkBeatmapLevel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using BeatSaverSharp; 5 | using MultiplayerCore.Beatmaps.Abstractions; 6 | using MultiplayerCore.Beatmaps.Packets; 7 | using MultiplayerCore.Beatmaps.Serializable; 8 | using UnityEngine; 9 | using static SongCore.Data.SongData; 10 | 11 | namespace MultiplayerCore.Beatmaps 12 | { 13 | /// 14 | /// Beatmap level data based on an MpBeatmapPacket from another player. 15 | /// 16 | class NetworkBeatmapLevel : MpBeatmap 17 | { 18 | public override string LevelHash { get; protected set; } 19 | 20 | public override string SongName => _packet.songName; 21 | public override string SongSubName => _packet.songSubName; 22 | public override string SongAuthorName => _packet.songAuthorName; 23 | public override string LevelAuthorName => _packet.levelAuthorName; 24 | public override float BeatsPerMinute => _packet.beatsPerMinute; 25 | public override float SongDuration => _packet.songDuration; 26 | 27 | public override Dictionary> Requirements => 28 | new() { { _packet.characteristicName, _packet.requirements } }; 29 | 30 | public override Dictionary> DifficultyColors => 31 | new() { { _packet.characteristicName, _packet.mapColors } }; 32 | 33 | public override Contributor[] Contributors => _packet.contributors; 34 | 35 | private readonly MpBeatmapPacket _packet; 36 | private readonly BeatSaver? _beatsaver; 37 | 38 | public NetworkBeatmapLevel(MpBeatmapPacket packet) 39 | { 40 | LevelHash = packet.levelHash; 41 | _packet = packet; 42 | } 43 | 44 | public NetworkBeatmapLevel(MpBeatmapPacket packet, BeatSaver beatsaver) 45 | { 46 | LevelHash = packet.levelHash; 47 | _packet = packet; 48 | _beatsaver = beatsaver; 49 | } 50 | 51 | private Task? _coverImageTask; 52 | 53 | public override Task TryGetCoverSpriteAsync(CancellationToken cancellationToken) 54 | { 55 | if (_coverImageTask == null) 56 | _coverImageTask = FetchCoverImage(cancellationToken); 57 | return _coverImageTask; 58 | } 59 | 60 | private async Task FetchCoverImage(CancellationToken cancellationToken) 61 | { 62 | if (_beatsaver == null) 63 | return null!; 64 | var beatmap = await _beatsaver.BeatmapByHash(LevelHash, cancellationToken); 65 | if (beatmap == null) 66 | return null!; 67 | byte[]? coverBytes = await beatmap.LatestVersion.DownloadCoverImage(cancellationToken); 68 | if (coverBytes == null || coverBytes.Length == 0) 69 | return null!; 70 | Texture2D texture = new Texture2D(2, 2); 71 | texture.LoadImage(coverBytes); 72 | return Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0, 0), 100.0f); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/OverridePatches/MultiplayerLevelLoaderOverride.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Objects; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using static IPA.Logging.Logger; 9 | using static MultiplayerLevelLoader; 10 | 11 | namespace MultiplayerCore.Patches.OverridePatches 12 | { 13 | [HarmonyPatch(typeof(MultiplayerLevelLoader))] 14 | internal class MultiplayerLevelLoaderOverride 15 | { 16 | 17 | [HarmonyPostfix] 18 | [HarmonyPatch(nameof(MultiplayerLevelLoader.LoadLevel))] 19 | private static void LoadLevel_override(MultiplayerLevelLoader __instance, ILevelGameplaySetupData gameplaySetupData, long initialStartTime) 20 | { 21 | Plugin.Logger.Debug("Called MultiplayerLevelLoader.LoadLevel Override Patch"); 22 | ((MpLevelLoader)__instance).LoadLevel_override(gameplaySetupData.beatmapKey.levelId); 23 | } 24 | 25 | [HarmonyPrefix] 26 | [HarmonyPatch(nameof(MultiplayerLevelLoader.Tick))] 27 | private static bool Tick_override_Pre(MultiplayerLevelLoader __instance, ref MultiplayerBeatmapLoaderState __state) 28 | { 29 | MpLevelLoader instance = (MpLevelLoader)__instance; 30 | __state = instance._loaderState; 31 | if (instance._loaderState == MultiplayerBeatmapLoaderState.NotLoading) 32 | { 33 | // Loader: not doing anything 34 | return false; 35 | } 36 | 37 | var levelId = instance._gameplaySetupData.beatmapKey.levelId; 38 | 39 | if (instance._loaderState == MultiplayerBeatmapLoaderState.WaitingForCountdown) 40 | { 41 | // Loader: level is loaded locally, waiting for countdown to transition to level 42 | // Modded behavior: wait until all players are ready before we transition 43 | 44 | if (instance._sessionManager.syncTime < instance._startTime) 45 | return false; 46 | 47 | // Ready check: player returned OK entitlement (load finished) OR already transitioned to gameplay 48 | var allPlayersReady = instance._sessionManager.connectedPlayers.All(p => 49 | instance._entitlementChecker.GetKnownEntitlement(p.userId, levelId) == EntitlementsStatus.Ok // level loaded 50 | || p.HasState("in_gameplay") // already playing 51 | || p.HasState("backgrounded") // not actively in game 52 | || !p.HasState("wants_to_play_next_level") // doesn't want to play (spectator) 53 | ); 54 | 55 | if (!allPlayersReady) 56 | return false; 57 | 58 | instance._logger.Debug($"All players finished loading"); 59 | } 60 | 61 | // Loader main: pending load 62 | return true; 63 | } 64 | 65 | 66 | [HarmonyPostfix] 67 | [HarmonyPatch(nameof(MultiplayerLevelLoader.Tick))] 68 | private static void Tick_override_Post(MultiplayerLevelLoader __instance, MultiplayerBeatmapLoaderState __state) 69 | { 70 | MpLevelLoader instance = (MpLevelLoader)__instance; 71 | 72 | bool loadJustFinished = __state == MultiplayerBeatmapLoaderState.LoadingBeatmap && instance._loaderState == MultiplayerBeatmapLoaderState.WaitingForCountdown; 73 | if (!loadJustFinished) return; 74 | 75 | // Loader main: pending load 76 | var levelId = instance._gameplaySetupData.beatmapKey.levelId; 77 | instance._rpcManager.SetIsEntitledToLevel(levelId, EntitlementsStatus.Ok); 78 | instance._logger.Debug($"Loaded level: {levelId}"); 79 | 80 | instance.UnloadLevelIfRequirementsNotMet(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/GraphAPIClientPatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Reflection; 6 | using System.Reflection.Emit; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using BGNet.Core.GameLift; 10 | using HarmonyLib; 11 | 12 | namespace MultiplayerCore.Patches 13 | { 14 | [HarmonyPatch] 15 | internal class GraphAPIClientPatch 16 | { 17 | 18 | private static readonly HttpClientHandler _handler = new HttpClientHandler() 19 | { 20 | UseCookies = false, 21 | }; 22 | 23 | private static readonly HttpClient _client = new(_handler); 24 | 25 | // TODO: If this ever breaks, just patch the GraphAPIClient ctor and replace HttpClient _client with a custom HttpClient that has a logging handler 26 | 27 | private static readonly MethodInfo _sendAsyncAttacher = 28 | SymbolExtensions.GetMethodInfo(() => SendAsync(null!, 0, CancellationToken.None)); 29 | 30 | private static readonly MethodInfo _sendAsyncMethod = typeof(HttpClient).GetMethod(nameof(HttpClient.SendAsync), 31 | new[] { typeof(HttpRequestMessage), typeof(HttpCompletionOption), typeof(CancellationToken) })!; 32 | 33 | private static MethodBase TargetMethod() 34 | { 35 | return AccessTools.FirstInner(typeof(GraphAPIClient), t => t.Name.StartsWith("d__5`1")) 36 | ?.MakeGenericType(typeof(GetMultiplayerInstanceResponse)) 37 | .GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance)!; 38 | } 39 | 40 | private static IEnumerable Transpiler(IEnumerable instructions) 41 | { 42 | Plugin.Logger.Trace("Transpiling GraphAPIClient.Post"); 43 | var codes = instructions.ToList(); 44 | for (var i = 0; i < codes.Count; i++) 45 | { 46 | Plugin.Logger.Trace($"Instruction at index {i}: {codes[i].opcode}"); 47 | if (codes[i].opcode == OpCodes.Callvirt) 48 | { 49 | Plugin.Logger.Trace($"Callvirt Method: {codes[i].operand}"); 50 | if (codes[i].Calls(_sendAsyncMethod)) 51 | { 52 | Plugin.Logger.Trace("Found SendAsync call"); 53 | var newCode = new CodeInstruction(OpCodes.Callvirt, _sendAsyncAttacher); 54 | codes[i] = newCode; 55 | } 56 | } 57 | } 58 | 59 | return codes.AsEnumerable(); 60 | } 61 | 62 | private static Task SendAsync(HttpRequestMessage request, 63 | HttpCompletionOption completionOption, 64 | CancellationToken cancellationToken) 65 | { 66 | Plugin.Logger.Debug("SendAsync MasterServer Request called"); 67 | var result = _client.SendAsync(request, completionOption, cancellationToken); 68 | 69 | result.ContinueWith(async task => 70 | { 71 | try 72 | { 73 | var response = task.Result; 74 | if (!response.IsSuccessStatusCode) 75 | { 76 | Plugin.Logger.Error( 77 | $"An error occurred while attempting to post to the Graph API: Uri '{request.RequestUri}' StatusCode: {(int)response.StatusCode}: {response.StatusCode}"); 78 | Plugin.Logger.Trace($"Response: {await response.Content.ReadAsStringAsync()}"); 79 | } 80 | } 81 | catch (Exception ex) 82 | { 83 | Plugin.Logger.Error( 84 | $"An error occurred while attempting to post to the Graph API: Uri '{request.RequestUri}' Exception Message: " + 85 | $"{ex.Message + (ex.InnerException != null ? " --> " + ex.InnerException.Message : "") + (ex.InnerException != null && ex.InnerException.InnerException != null ? " --> " + ex.InnerException.InnerException.Message : "")}"); 86 | Plugin.Logger.Trace(ex); 87 | } 88 | }); 89 | return result; 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /MultiplayerCore/UI/MpLoadingIndicator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using BeatSaberMarkupLanguage; 5 | using BeatSaberMarkupLanguage.Attributes; 6 | using BeatSaberMarkupLanguage.Components; 7 | using MultiplayerCore.Objects; 8 | using UnityEngine; 9 | using Zenject; 10 | 11 | namespace MultiplayerCore.UI 12 | { 13 | internal class MpLoadingIndicator : NotifiableBase, IInitializable, IDisposable, ITickable, IProgress 14 | { 15 | public const string ResourcePath = "MultiplayerCore.UI.LoadingIndicator.bsml"; 16 | 17 | private readonly IMultiplayerSessionManager _sessionManager; 18 | private readonly ILobbyGameStateController _gameStateController; 19 | private readonly ILobbyPlayersDataModel _playersDataModel; 20 | private readonly MpEntitlementChecker _entitlementChecker; 21 | private readonly MpLevelLoader _levelLoader; 22 | private readonly CenterStageScreenController _screenController; 23 | 24 | private LoadingControl _loadingControl = null!; 25 | private bool _isDownloading; 26 | 27 | internal MpLoadingIndicator( 28 | IMultiplayerSessionManager sessionManager, 29 | ILobbyGameStateController gameStateController, 30 | ILobbyPlayersDataModel playersDataModel, 31 | NetworkPlayerEntitlementChecker entitlementChecker, 32 | MpLevelLoader levelLoader, 33 | CenterStageScreenController screenController) 34 | { 35 | _sessionManager = sessionManager; 36 | _gameStateController = gameStateController; 37 | _playersDataModel = playersDataModel; 38 | _entitlementChecker = (entitlementChecker as MpEntitlementChecker)!; 39 | _levelLoader = levelLoader; 40 | _screenController = screenController; 41 | } 42 | 43 | public void Initialize() 44 | { 45 | BSMLParser.Instance.Parse(BeatSaberMarkupLanguage.Utilities.GetResourceContent(Assembly.GetExecutingAssembly(), ResourcePath), _screenController.gameObject, this); 46 | GameObject existingLoadingControl = Resources.FindObjectsOfTypeAll().First().gameObject; 47 | GameObject loadingControlGO = GameObject.Instantiate(existingLoadingControl, _vert.transform); 48 | _loadingControl = loadingControlGO.GetComponent(); 49 | _loadingControl.Hide(); 50 | 51 | _levelLoader.progressUpdated += Report; 52 | } 53 | 54 | public void Dispose() 55 | { 56 | _levelLoader.progressUpdated -= Report; 57 | } 58 | 59 | public void Tick() 60 | { 61 | if (_isDownloading) 62 | return; 63 | 64 | if (_screenController.countdownShown && _sessionManager.syncTime >= _gameStateController.startTime && _gameStateController.levelStartInitiated && _levelLoader.CurrentLoadingData != null) 65 | _loadingControl.ShowLoading($"{_playersDataModel.Count(x => _entitlementChecker.GetKnownEntitlement(x.Key, _levelLoader.CurrentLoadingData.beatmapKey.levelId) == EntitlementsStatus.Ok) + 1} of {_playersDataModel.Count - 1} players ready..."); 66 | else 67 | _loadingControl.Hide(); 68 | } 69 | 70 | public void Report(double value) 71 | { 72 | _isDownloading = value < 1.0; 73 | _loadingControl.ShowDownloadingProgress($"Downloading ({value * 100:F2}%)...", (float)value); 74 | } 75 | 76 | [UIObject("vert")] 77 | private GameObject _vert = null!; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /MultiplayerCore/Helpers/SongCoreConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace MultiplayerCore.Helpers 4 | { 5 | /// 6 | /// Helper for reading SongCore config data. 7 | /// 8 | public static class SongCoreConfig 9 | { 10 | private static object? _songCoreConfig = null; 11 | 12 | private static object? TryGetInstance() 13 | { 14 | if (_songCoreConfig == null) 15 | { 16 | _songCoreConfig = typeof(SongCore.Plugin) 17 | .GetProperty("Configuration", BindingFlags.NonPublic | BindingFlags.Static) 18 | ?.GetValue(null); 19 | } 20 | 21 | return _songCoreConfig; 22 | } 23 | 24 | public static object? TryGetValue(string key) 25 | { 26 | var configObject = TryGetInstance(); 27 | if (configObject == null) 28 | return null; 29 | 30 | var configProp = configObject.GetType().GetProperty(key); 31 | if (configProp == null) 32 | return null; 33 | 34 | return configProp.GetValue(configObject); 35 | } 36 | 37 | public static object? TrySetValue(string key, object value) 38 | { 39 | var configObject = TryGetInstance(); 40 | 41 | if (configObject == null) 42 | return null; 43 | 44 | var configProp = configObject.GetType().GetProperty(key); 45 | 46 | if (configProp == null) 47 | return null; 48 | 49 | configProp.SetValue(configObject, value); 50 | return true; 51 | } 52 | 53 | public static bool TryGetBool(string key) 54 | { 55 | var value = TryGetValue(key); 56 | if (value == null) 57 | return false; 58 | 59 | return (bool) value; 60 | } 61 | 62 | public static bool TrySetBool(string key) 63 | { 64 | var value = TryGetValue(key); 65 | if (value == null) 66 | return false; 67 | 68 | return (bool) value; 69 | } 70 | 71 | public static bool CustomSongNoteColors 72 | { 73 | get => TryGetBool("CustomSongNoteColors"); 74 | set => TrySetValue("CustomSongNoteColors", value); 75 | } 76 | 77 | public static bool CustomSongObstacleColors 78 | { 79 | get => TryGetBool("CustomSongObstacleColors"); 80 | set => TrySetValue("CustomSongObstacleColors", value); 81 | } 82 | 83 | public static bool CustomSongEnvironmentColors 84 | { 85 | get => TryGetBool("CustomSongEnvironmentColors"); 86 | set => TrySetValue("CustomSongEnvironmentColors", value); 87 | } 88 | 89 | public static bool AnyCustomSongColors 90 | { 91 | get => CustomSongNoteColors || CustomSongObstacleColors || CustomSongEnvironmentColors; 92 | set 93 | { 94 | if (value) 95 | { 96 | CustomSongNoteColors = true; 97 | CustomSongObstacleColors = true; 98 | CustomSongEnvironmentColors = true; 99 | } 100 | else 101 | { 102 | CustomSongNoteColors = false; 103 | CustomSongObstacleColors = false; 104 | CustomSongEnvironmentColors = false; 105 | } 106 | } 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/LocalBeatmapLevel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using MultiplayerCore.Beatmaps.Abstractions; 6 | using MultiplayerCore.Beatmaps.Serializable; 7 | using UnityEngine; 8 | using static SongCore.Data.SongData; 9 | 10 | namespace MultiplayerCore.Beatmaps 11 | { 12 | /// 13 | /// Beatmap level data that was loaded locally by SongCore. 14 | /// 15 | public class LocalBeatmapLevel : MpBeatmap 16 | { 17 | public override string LevelHash { get; protected set; } 18 | 19 | public override string SongName => _localBeatmapLevel.songName; 20 | public override string SongSubName => _localBeatmapLevel.songSubName; 21 | public override string SongAuthorName => _localBeatmapLevel.songAuthorName; 22 | public override string LevelAuthorName => string.Join(", ", _localBeatmapLevel.allMappers); 23 | 24 | public override float BeatsPerMinute => _localBeatmapLevel.beatsPerMinute; 25 | public override float SongDuration => _localBeatmapLevel.songDuration; 26 | 27 | public override Dictionary> Requirements 28 | { 29 | get 30 | { 31 | Dictionary> reqs = new(); 32 | var difficulties = SongCore.Collections.GetCustomLevelSongData(LevelID)?._difficulties; // TODO: Check if we need to call levelIDsForHash 33 | if (difficulties == null) 34 | return new(); 35 | foreach (var difficulty in difficulties) 36 | { 37 | if (!reqs.ContainsKey(difficulty._beatmapCharacteristicName)) 38 | reqs.Add(difficulty._beatmapCharacteristicName, new()); 39 | reqs[difficulty._beatmapCharacteristicName][difficulty._difficulty] = difficulty.additionalDifficultyData._requirements; 40 | } 41 | return reqs; 42 | } 43 | } 44 | 45 | public override Dictionary> DifficultyColors 46 | { 47 | get 48 | { 49 | Dictionary> colors = new(); 50 | var difficulties = SongCore.Collections.GetCustomLevelSongData(LevelID)?._difficulties; 51 | if (difficulties == null) 52 | return new(); 53 | foreach (var difficulty in difficulties) 54 | { 55 | if (!colors.ContainsKey(difficulty._beatmapCharacteristicName)) 56 | colors.Add(difficulty._beatmapCharacteristicName, new()); 57 | colors[difficulty._beatmapCharacteristicName][difficulty._difficulty] 58 | = new DifficultyColors(difficulty._colorLeft, difficulty._colorRight, difficulty._envColorLeft, difficulty._envColorRight, difficulty._envColorLeftBoost, difficulty._envColorRightBoost, difficulty._obstacleColor); 59 | } 60 | return colors; 61 | } 62 | } 63 | 64 | public override Contributor[] Contributors => SongCore.Collections.GetCustomLevelSongData(LevelID)?.contributors ?? new Contributor[0]; 65 | 66 | private readonly BeatmapLevel _localBeatmapLevel; 67 | 68 | public LocalBeatmapLevel(string hash, BeatmapLevel localBeatmapLevel) 69 | { 70 | LevelHash = hash; 71 | _localBeatmapLevel = localBeatmapLevel; 72 | } 73 | 74 | public override Task TryGetCoverSpriteAsync(CancellationToken cancellationToken) 75 | => _localBeatmapLevel.previewMediaData.GetCoverSpriteAsync(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/BeatSaverBeatmapLevel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using BeatSaverSharp.Models; 7 | using MultiplayerCore.Beatmaps.Abstractions; 8 | using UnityEngine; 9 | using static BeatSaverSharp.Models.BeatmapDifficulty; 10 | using static SongCore.Data.SongData; 11 | 12 | namespace MultiplayerCore.Beatmaps 13 | { 14 | /// 15 | /// Beatmap level data that was loaded remotely from the BeatSaver API. 16 | /// 17 | public class BeatSaverBeatmapLevel : MpBeatmap 18 | { 19 | public override string LevelHash { get; protected set; } 20 | 21 | public override string SongName => _beatmap.Metadata.SongName; 22 | public override string SongSubName => _beatmap.Metadata.SongSubName; 23 | public override string SongAuthorName => _beatmap.Metadata.SongAuthorName; 24 | public override string LevelAuthorName => _beatmap.Metadata.LevelAuthorName; 25 | public override float BeatsPerMinute => _beatmap.Metadata.BPM; 26 | public override float SongDuration => _beatmap.Metadata.Duration; 27 | 28 | public override Dictionary> Requirements 29 | { 30 | get 31 | { 32 | Dictionary> reqs = new(); 33 | var difficulties = _beatmap.LatestVersion.Difficulties; 34 | foreach (var difficulty in difficulties) 35 | { 36 | var characteristic = difficulty.Characteristic.ToString(); 37 | var difficultyKey = difficulty.Difficulty switch 38 | { 39 | BeatSaverBeatmapDifficulty.Easy => BeatmapDifficulty.Easy, 40 | BeatSaverBeatmapDifficulty.Normal => BeatmapDifficulty.Normal, 41 | BeatSaverBeatmapDifficulty.Hard => BeatmapDifficulty.Hard, 42 | BeatSaverBeatmapDifficulty.Expert => BeatmapDifficulty.Expert, 43 | BeatSaverBeatmapDifficulty.ExpertPlus => BeatmapDifficulty.ExpertPlus, 44 | _ => throw new ArgumentOutOfRangeException(nameof(difficulty.Difficulty), $"Unexpected difficulty value: {difficulty.Difficulty}") 45 | }; 46 | if (!reqs.ContainsKey(characteristic)) 47 | reqs.Add(characteristic, new()); 48 | string[] diffReqs = new string[0]; 49 | //if (difficulty.Chroma) 50 | // diffReqs.Append("Chroma"); 51 | if (difficulty.NoodleExtensions) 52 | diffReqs.Append("Noodle Extensions"); 53 | if (difficulty.MappingExtensions) 54 | diffReqs.Append("Mapping Extensions"); 55 | reqs[characteristic][difficultyKey] = diffReqs; 56 | } 57 | return reqs; 58 | } 59 | } 60 | 61 | public override Contributor[] Contributors => new Contributor[] { new Contributor 62 | { 63 | _role = "Uploader", 64 | _name = _beatmap.Uploader.Name, 65 | _iconPath = "" 66 | }}; 67 | 68 | private readonly Beatmap _beatmap; 69 | 70 | public BeatSaverBeatmapLevel(string hash, Beatmap beatmap) 71 | { 72 | LevelHash = hash; 73 | _beatmap = beatmap; 74 | } 75 | 76 | public override async Task TryGetCoverSpriteAsync(CancellationToken cancellationToken) 77 | { 78 | byte[]? coverBytes = await _beatmap.LatestVersion.DownloadCoverImage(cancellationToken); 79 | if (coverBytes == null || coverBytes.Length == 0) 80 | return null!; 81 | Texture2D texture = new Texture2D(2, 2); 82 | texture.LoadImage(coverBytes); 83 | return Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0, 0), 100.0f); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /MultiplayerCore/ScoreSyncState/MpScoreSyncStateManager.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Networking; 2 | using System; 3 | using Zenject; 4 | using SiraUtil.Affinity; 5 | using SiraUtil.Logging; 6 | 7 | 8 | namespace MultiplayerCore.NodePoseSyncState 9 | { 10 | internal class MpScoreSyncStateManager : IInitializable, IDisposable, IAffinity 11 | { 12 | public long? DeltaUpdateFrequency { get; private set; } 13 | public long? FullStateUpdateFrequency { get; private set; } 14 | public bool ShouldForceUpdate { get; private set; } 15 | 16 | private readonly MpPacketSerializer _packetSerializer; 17 | private readonly SiraLog _logger; 18 | 19 | MpScoreSyncStateManager(MpPacketSerializer packetSerializer, SiraLog logger) 20 | { 21 | _packetSerializer = packetSerializer; 22 | _logger = logger; 23 | } 24 | 25 | public void Initialize() => _packetSerializer.RegisterCallback(HandleUpdateFrequencyUpdated); 26 | 27 | public void Dispose() => _packetSerializer.UnregisterCallback(); 28 | 29 | private void HandleUpdateFrequencyUpdated(MpScoreSyncStatePacket data, IConnectedPlayer player) 30 | { 31 | if (player.isConnectionOwner) 32 | { 33 | _logger.Debug("Updating node pose sync frequency to following values: " + 34 | $"delta: {data.deltaUpdateFrequency}ms, full: {data.fullStateUpdateFrequency}ms"); 35 | ShouldForceUpdate = DeltaUpdateFrequency != data.deltaUpdateFrequency || 36 | FullStateUpdateFrequency != data.fullStateUpdateFrequency; 37 | DeltaUpdateFrequency = data.deltaUpdateFrequency; 38 | FullStateUpdateFrequency = data.fullStateUpdateFrequency; 39 | } 40 | } 41 | 42 | [AffinityPrefix] 43 | [AffinityPatch(typeof(ScoreSyncStateManager), "deltaUpdateFrequencyMs", AffinityMethodType.Getter)] 44 | private bool GetDeltaUpdateFrequencyMs(ref long __result) 45 | { 46 | if (DeltaUpdateFrequency.HasValue) 47 | { 48 | __result = DeltaUpdateFrequency.Value; 49 | return false; 50 | } 51 | return true; 52 | } 53 | 54 | [AffinityPrefix] 55 | [AffinityPatch(typeof(ScoreSyncStateManager), "fullStateUpdateFrequencyMs", AffinityMethodType.Getter)] 56 | private bool GetFullStateUpdateFrequencyMs(ref long __result) 57 | { 58 | if (FullStateUpdateFrequency.HasValue) 59 | { 60 | __result = FullStateUpdateFrequency.Value; 61 | return false; 62 | } 63 | return true; 64 | } 65 | 66 | [AffinityPrefix] 67 | [AffinityPatch(typeof(MultiplayerSyncStateManager), nameof(ScoreSyncStateManager.TryCreateLocalState))] 68 | private void TryCreateLocalState(MultiplayerSyncStateManager __instance) 69 | { 70 | if (ShouldForceUpdate) 71 | { 72 | _logger.Debug("Forcing new state buffer update"); 73 | __instance._localState = null; 74 | ShouldForceUpdate = false; 75 | } 76 | } 77 | 78 | [AffinityPrefix] 79 | [AffinityPatch(typeof(MultiplayerSyncStateManager), nameof(ScoreSyncStateManager.HandlePlayerConnected))] 80 | private void HandlePlayerConnected(MultiplayerSyncStateManager __instance) 81 | { 82 | if (ShouldForceUpdate) 83 | { 84 | _logger.Debug("Forcing new state buffer update"); 85 | __instance._localState = null; 86 | ShouldForceUpdate = false; 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /MultiplayerCore/Models/MpStatusData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | // ReSharper disable InconsistentNaming 5 | namespace MultiplayerCore.Models 6 | { 7 | [Serializable] 8 | public class MpStatusData : MultiplayerStatusData 9 | { 10 | /// 11 | /// Handled by MultiplayerCore. If defined, and if a mod with a bad version is found, the multiplayer status 12 | /// check fails and MUR-5 is returned. 13 | /// 14 | public RequiredMod[]? requiredMods 15 | { 16 | get => required_mods; 17 | set => required_mods = value; 18 | } 19 | public RequiredMod[]? required_mods; 20 | 21 | /// 22 | /// Handled by MultiplayerCore. If defined, and if the current game version exceeds this version, the 23 | /// multiplayer status check fails and MUR-6 is returned. 24 | /// 25 | public string? maximumAppVersion 26 | { 27 | get => maximum_app_version; 28 | set => maximum_app_version = value; 29 | } 30 | public string? maximum_app_version; 31 | 32 | /// 33 | /// Information only. Indicates whether dedicated server connections should use SSL/TLS. Currently, most modded 34 | /// multiplayer servers do not use encryption. 35 | /// 36 | public bool useSsl 37 | { 38 | get => use_ssl; 39 | set => use_ssl = value; 40 | } 41 | public bool use_ssl; 42 | 43 | /// 44 | /// Information only. Master server display name. 45 | /// 46 | public string? name { get; set; } 47 | 48 | /// 49 | /// Information only. Master server display description. 50 | /// 51 | public string? description { get; set; } 52 | 53 | /// 54 | /// Information only. Master server display image URL. 55 | /// 56 | public string? imageUrl 57 | { 58 | get => image_url; 59 | set => image_url = value; 60 | } 61 | public string? image_url; 62 | 63 | /// 64 | /// Information only. Maximum player count when creating new lobbies. 65 | /// 66 | public int maxPlayers 67 | { 68 | get => max_players; 69 | set => max_players = value; 70 | } 71 | public int max_players; 72 | 73 | /// 74 | /// Information only. Server capability: per-player modifiers. 75 | /// 76 | public bool supportsPPModifiers 77 | { 78 | get => supports_pp_modifiers; 79 | set => supports_pp_modifiers = value; 80 | } 81 | public bool supports_pp_modifiers; 82 | 83 | /// 84 | /// Information only. Server capability: per-player difficulties. 85 | /// 86 | public bool supportsPPDifficulties 87 | { 88 | get => supports_pp_difficulties; 89 | set => supports_pp_difficulties = value; 90 | } 91 | public bool supports_pp_difficulties; 92 | 93 | /// 94 | /// Information only. Server capability: per-player level selection. 95 | /// 96 | public bool supportsPPMaps 97 | { 98 | get => supports_pp_maps; 99 | set => supports_pp_maps = value; 100 | } 101 | public bool supports_pp_maps; 102 | 103 | [Serializable] 104 | public class RequiredMod 105 | { 106 | /// 107 | /// BSIPA Mod ID. 108 | /// 109 | public string id = null!; 110 | 111 | /// 112 | /// Minimum version of the mod required, if installed. 113 | /// 114 | public string version = null!; 115 | 116 | /// 117 | /// Indicates whether the mod must be installed. 118 | /// 119 | public bool required = false; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /MultiplayerCore/Patches/LoggingPatch.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using LiteNetLib.Utils; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Reflection.Emit; 6 | 7 | namespace MultiplayerCore.Patches 8 | { 9 | [HarmonyPatch] 10 | internal class LoggingPatch 11 | { 12 | [HarmonyTranspiler] 13 | [HarmonyPatch(typeof(ConnectedPlayerManager), "HandleNetworkReceive")] 14 | private static IEnumerable PacketErrorLogger(IEnumerable instructions, ILGenerator gen) 15 | { 16 | LocalBuilder localException = gen.DeclareLocal(typeof(Exception)); 17 | localException.SetLocalSymInfo("ex"); 18 | 19 | foreach (CodeInstruction? code in instructions) 20 | { 21 | if (code.opcode == OpCodes.Pop) 22 | { 23 | CodeInstruction current = new CodeInstruction(OpCodes.Stloc, localException); 24 | current.blocks.Add(new ExceptionBlock(ExceptionBlockType.BeginCatchBlock)); 25 | yield return current; // Store exception in local 26 | current = new CodeInstruction(OpCodes.Ldarg_2); 27 | yield return current; // Load packet onto stack 28 | current = new CodeInstruction(OpCodes.Ldloc_3); 29 | yield return current; // Load player onto stack 30 | current = new CodeInstruction(OpCodes.Ldloc, localException); 31 | yield return current; // Load exception onto stack 32 | current = new CodeInstruction(OpCodes.Callvirt, SymbolExtensions.GetMethodInfo(() => LogPacketError(null!, null!, null!))); 33 | yield return current; 34 | } 35 | else 36 | { 37 | yield return code; 38 | } 39 | } 40 | } 41 | 42 | private static void LogPacketError(NetDataReader reader, IConnectedPlayer p, Exception ex) 43 | { 44 | Plugin.Logger.Warn($"An exception was thrown processing a packet from player '{p?.userName ?? ""}|{p?.userId ?? " < NULL > "}': {ex.Message}"); 45 | Plugin.Logger.Debug(ex); 46 | #if (DEBUG) 47 | Plugin.Logger.Error($"Errored packet Postion={reader.Position}, RawDataSize={reader.RawDataSize} RawData: {BitConverter.ToString(reader.RawData)}"); 48 | try 49 | { 50 | reader.SkipBytes(-reader.Position); 51 | byte header1, header2, header3; 52 | if (!reader.TryGetByte(out header1) || !reader.TryGetByte(out header2) || 53 | !reader.TryGetByte(out header3) || reader.AvailableBytes == 0) 54 | { 55 | Plugin.Logger.Debug("Failed to get RoutingHeader"); 56 | } 57 | else 58 | { 59 | Plugin.Logger.Debug($"Routing Header bytes=({header1},{header2},{header3})"); 60 | int index = 0; 61 | while (!reader.EndOfData && index < 100) 62 | { 63 | Plugin.Logger.Debug($"Iteration='{index}' Attempt read data length from packet"); 64 | int length = (int)reader.GetVarUInt(); 65 | int subIteration = 0; 66 | while (length > 0 && length <= reader.AvailableBytes && subIteration < 100) 67 | { 68 | Plugin.Logger.Debug($"Iteration='{index}' subIteration='{subIteration}' Length='{length}' AvailableBytes={reader.AvailableBytes}"); 69 | byte packetId = reader.GetByte(); 70 | length--; 71 | Plugin.Logger.Debug($"Iteration='{index}' subIteration='{subIteration}' PacketId='{packetId}' RemainingLength='{length}'"); 72 | subIteration++; 73 | } 74 | reader.SkipBytes(Math.Min(length, reader.AvailableBytes)); 75 | Plugin.Logger.Debug($"Iteration='{index}' RemainingBytes='{reader.AvailableBytes}'"); 76 | index++; 77 | } 78 | } 79 | } 80 | catch 81 | { 82 | } 83 | finally 84 | { 85 | Plugin.Logger.Debug($"Finished Debug Logging for Packet!"); 86 | } 87 | #endif 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /MultiplayerCore/NodePoseSyncState/MpNodePoseSyncStateManager.cs: -------------------------------------------------------------------------------- 1 | using MultiplayerCore.Networking; 2 | using System; 3 | using Zenject; 4 | using SiraUtil.Affinity; 5 | using SiraUtil.Logging; 6 | 7 | 8 | namespace MultiplayerCore.NodePoseSyncState 9 | { 10 | internal class MpNodePoseSyncStateManager : IInitializable, IDisposable, IAffinity 11 | { 12 | public long? DeltaUpdateFrequency { get; private set; } 13 | public long? FullStateUpdateFrequency { get; private set; } 14 | 15 | public bool ShouldForceUpdate { get; private set; } 16 | 17 | private readonly MpPacketSerializer _packetSerializer; 18 | private readonly SiraLog _logger; 19 | 20 | MpNodePoseSyncStateManager(MpPacketSerializer packetSerializer, SiraLog logger) 21 | { 22 | _packetSerializer = packetSerializer; 23 | _logger = logger; 24 | } 25 | 26 | public void Initialize() => _packetSerializer.RegisterCallback(HandleUpdateFrequencyUpdated); 27 | 28 | public void Dispose() => _packetSerializer.UnregisterCallback(); 29 | 30 | private void HandleUpdateFrequencyUpdated(MpNodePoseSyncStatePacket data, IConnectedPlayer player) 31 | { 32 | if (player.isConnectionOwner) 33 | { 34 | _logger.Debug("Updating node pose sync frequency to following values: " + 35 | $"delta: {data.deltaUpdateFrequency}ms, full: {data.fullStateUpdateFrequency}ms"); 36 | ShouldForceUpdate = DeltaUpdateFrequency != data.deltaUpdateFrequency || 37 | FullStateUpdateFrequency != data.fullStateUpdateFrequency; 38 | DeltaUpdateFrequency = data.deltaUpdateFrequency; 39 | FullStateUpdateFrequency = data.fullStateUpdateFrequency; 40 | } 41 | } 42 | 43 | [AffinityPrefix] 44 | [AffinityPatch(typeof(NodePoseSyncStateManager), "deltaUpdateFrequencyMs", AffinityMethodType.Getter)] 45 | private bool GetDeltaUpdateFrequencyMs(ref long __result) 46 | { 47 | if (DeltaUpdateFrequency.HasValue) 48 | { 49 | _logger.Debug($"Returning delta update frequency: {DeltaUpdateFrequency.Value}ms"); 50 | __result = DeltaUpdateFrequency.Value; 51 | return false; 52 | } 53 | return true; 54 | } 55 | 56 | [AffinityPrefix] 57 | [AffinityPatch(typeof(NodePoseSyncStateManager), "fullStateUpdateFrequencyMs", AffinityMethodType.Getter)] 58 | private bool GetFullStateUpdateFrequencyMs(ref long __result) 59 | { 60 | if (FullStateUpdateFrequency.HasValue) 61 | { 62 | _logger.Debug($"Returning full state update frequency: {FullStateUpdateFrequency.Value}ms"); 63 | __result = FullStateUpdateFrequency.Value; 64 | return false; 65 | } 66 | return true; 67 | } 68 | 69 | [AffinityPrefix] 70 | [AffinityPatch(typeof(MultiplayerSyncStateManager), nameof(NodePoseSyncStateManager.TryCreateLocalState))] 71 | private void TryCreateLocalState(MultiplayerSyncStateManager __instance) 72 | { 73 | if (ShouldForceUpdate) 74 | { 75 | _logger.Debug("Forcing new state buffer update"); 76 | __instance._localState = null; 77 | ShouldForceUpdate = false; 78 | } 79 | } 80 | 81 | [AffinityPrefix] 82 | [AffinityPatch(typeof(MultiplayerSyncStateManager), nameof(NodePoseSyncStateManager.HandlePlayerConnected))] 83 | private void HandlePlayerConnected(MultiplayerSyncStateManager __instance) 84 | { 85 | if (ShouldForceUpdate) 86 | { 87 | _logger.Debug("Forcing new state buffer update"); 88 | __instance._localState = null; 89 | ShouldForceUpdate = false; 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/UpdateMapPatcher.cs: -------------------------------------------------------------------------------- 1 | // using BeatSaverSharp; 2 | // using HarmonyLib; 3 | // using IPA.Utilities.Async; 4 | // using MultiplayerCore.Beatmaps.Abstractions; 5 | // using SiraUtil.Affinity; 6 | // using System.Collections.Generic; 7 | // using System.Linq; 8 | // using System.Reflection; 9 | // using System.Reflection.Emit; 10 | // using System.Threading; 11 | // using System.Threading.Tasks; 12 | // 13 | // namespace MultiplayerCore.Patchers 14 | // { 15 | // [HarmonyPatch] 16 | // internal class UpdateMapPatcher : IAffinity 17 | // { 18 | // public const string PlayersMissingLevelTextKey = "LABEL_PLAYERS_MISSING_ENTITLEMENT"; 19 | // 20 | // private CancellationTokenSource? _beatmapCts; 21 | // 22 | // private LobbySetupViewController _lobbySetupViewController; 23 | // private readonly ILobbyPlayersDataModel _playersDataModel; 24 | // private readonly BeatSaver _beatsaver; 25 | // 26 | // public UpdateMapPatcher( 27 | // LobbySetupViewController lobbySetupViewController, 28 | // ILobbyPlayersDataModel playersDataModel, 29 | // BeatSaver beatsaver) 30 | // { 31 | // _lobbySetupViewController = lobbySetupViewController; 32 | // _playersDataModel = playersDataModel; 33 | // _beatsaver = beatsaver; 34 | // } 35 | // 36 | // [AffinityPrefix] 37 | // [AffinityPatch(typeof(GameServerLobbyFlowCoordinator), nameof(GameServerLobbyFlowCoordinator.HandleMenuRpcManagerSetPlayersMissingEntitlementsToLevel))] 38 | // private bool SetPlayersMissingEntitlementsToLevel(PlayersMissingEntitlementsNetSerializable playersMissingEntitlements) 39 | // { 40 | // var levelId = _playersDataModel[_playersDataModel.localUserId].beatmapLevel?.beatmapLevel.levelID; 41 | // var levelHash = Utilities.HashForLevelID(levelId); 42 | // if (levelId is null || levelHash is null) 43 | // return true; 44 | // _beatmapCts?.Cancel(); 45 | // _beatmapCts = new CancellationTokenSource(); 46 | // _ = Task.Run(() => FetchAndShowError(levelHash, playersMissingEntitlements, _beatmapCts.Token), _beatmapCts.Token); 47 | // return false; 48 | // } 49 | // 50 | // private async Task FetchAndShowError(string levelHash, PlayersMissingEntitlementsNetSerializable playersMissingEntitlements, CancellationToken cancellationToken) 51 | // { 52 | // var beatmap = await _beatsaver.BeatmapByHash(levelHash, cancellationToken); 53 | // 54 | // string errorText = PlayersMissingLevelTextKey; 55 | // if (beatmap?.LatestVersion.Hash != levelHash) 56 | // errorText = "Click here to update this song. These players cannot download the older version"; 57 | // if (_playersDataModel[_playersDataModel.localUserId].beatmapLevel?.beatmapLevel is MpBeatmap beatmapLevel && beatmapLevel.Requirements.Any()) 58 | // errorText = "This map has mod requirements that these players may not have"; 59 | // 60 | // await UnityMainThreadTaskScheduler.Factory.StartNew(() => SetPlayersMissingLevelText(playersMissingEntitlements, errorText)); 61 | // } 62 | // 63 | // private static readonly FieldInfo _errorField = AccessTools.Field(typeof(UpdateMapPatcher), nameof(_errorText)); 64 | // 65 | // [HarmonyReversePatch] 66 | // [HarmonyPatch(typeof(GameServerLobbyFlowCoordinator), nameof(GameServerLobbyFlowCoordinator.HandleMenuRpcManagerSetPlayersMissingEntitlementsToLevel))] 67 | // private static void SetPlayersMissingLevelText(PlayersMissingEntitlementsNetSerializable playersMissingEntitlements, string errorText) 68 | // { 69 | // _errorText = errorText; 70 | // IEnumerable Transpiler(IEnumerable instructions) => 71 | // new CodeMatcher(instructions) 72 | // .Start() 73 | // .RemoveInstructions(2) 74 | // .Set(OpCodes.Ldc_I4_1, null) 75 | // .MatchForward(false, new CodeMatch(i => i.opcode == OpCodes.Ldstr && i.OperandIs(PlayersMissingLevelTextKey))) 76 | // .Set(OpCodes.Ldsfld, _errorField) 77 | // .InstructionEnumeration(); 78 | // _ = Transpiler(null!); 79 | // } 80 | // 81 | // private static string? _errorText = null; 82 | // } 83 | // } 84 | // TODO Review / test / rework as needed -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/Providers/MpBeatmapLevelProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using BeatSaverSharp; 5 | using MultiplayerCore.Beatmaps.Abstractions; 6 | using MultiplayerCore.Beatmaps.Packets; 7 | using SiraUtil.Logging; 8 | using SiraUtil.Zenject; 9 | 10 | namespace MultiplayerCore.Beatmaps.Providers 11 | { 12 | public class MpBeatmapLevelProvider 13 | { 14 | private readonly BeatSaver _beatsaver; 15 | private readonly SiraLog _logger; 16 | private readonly Dictionary _hashToNetworkMaps = new(); 17 | private readonly ConcurrentDictionary> _hashToBeatsaverMaps = new(); 18 | 19 | internal MpBeatmapLevelProvider( 20 | UBinder beatsaver, 21 | SiraLog logger) 22 | { 23 | _beatsaver = beatsaver.Value; 24 | _logger = logger; 25 | } 26 | 27 | /// 28 | /// Gets an for the specified level hash. 29 | /// 30 | /// The hash of the level to get 31 | /// An with a matching level hash 32 | public async Task GetBeatmap(string levelHash) 33 | => GetBeatmapFromLocalBeatmaps(levelHash) 34 | ?? await GetBeatmapFromBeatSaver(levelHash); 35 | 36 | /// 37 | /// Gets an for the specified level hash from local, already downloaded beatmaps. 38 | /// 39 | /// The hash of the level to get 40 | /// An with a matching level hash, or null if none was found. 41 | public MpBeatmap? GetBeatmapFromLocalBeatmaps(string levelHash) 42 | { 43 | var localBeatmapLevel = SongCore.Loader.GetLevelByHash(levelHash); 44 | if (localBeatmapLevel == null) 45 | return null; 46 | 47 | return new LocalBeatmapLevel(levelHash, localBeatmapLevel); 48 | } 49 | 50 | /// 51 | /// Gets an for the specified level hash from BeatSaver. 52 | /// 53 | /// The hash of the level to get 54 | /// An with a matching level hash, or null if none was found. 55 | public async Task GetBeatmapFromBeatSaver(string levelHash) 56 | { 57 | if (!_hashToBeatsaverMaps.TryGetValue(levelHash, out var map)) 58 | { 59 | map = Task.Run(async () => 60 | { 61 | var beatmap = await _beatsaver.BeatmapByHash(levelHash); 62 | if (beatmap != null) 63 | { 64 | MpBeatmap bmap = new BeatSaverBeatmapLevel(levelHash, beatmap); 65 | return bmap; 66 | } 67 | 68 | return null; 69 | }); 70 | 71 | _hashToBeatsaverMaps[levelHash] = map; 72 | } 73 | 74 | var bmap = await map; 75 | if (bmap == null) _hashToBeatsaverMaps.TryRemove(levelHash, out _); // Ensure we remove null bmaps 76 | return bmap; 77 | } 78 | 79 | public BeatSaverPreviewMediaData MakeBeatSaverPreviewMediaData(string levelHash) => new BeatSaverPreviewMediaData(_beatsaver, levelHash); 80 | 81 | /// 82 | /// Gets an from the information in the provided packet. 83 | /// 84 | /// The packet to get preview data from 85 | /// An with a cover from BeatSaver. 86 | public MpBeatmap GetBeatmapFromPacket(MpBeatmapPacket packet) 87 | { 88 | if (_hashToNetworkMaps.TryGetValue(packet.levelHash, out var map)) return map; 89 | map = new NetworkBeatmapLevel(packet); 90 | _hashToNetworkMaps.Add(packet.levelHash, map); 91 | return map; 92 | } 93 | 94 | /// 95 | /// Gets an from the information in the provided packet. 96 | /// 97 | /// The hash of the packet we want 98 | /// An with a cover from BeatSaver. 99 | public MpBeatmap? TryGetBeatmapFromPacketHash(string hash) 100 | { 101 | if (_hashToNetworkMaps.TryGetValue(hash, out var map)) return map; 102 | return null; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/IntroAnimationPatches.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Reflection.Emit; 7 | using UnityEngine; 8 | using UnityEngine.Playables; 9 | using UnityEngine.Timeline; 10 | 11 | namespace MultiplayerCore.Patches 12 | { 13 | [HarmonyPatch] 14 | internal class IntroAnimationPatches 15 | { 16 | private static PlayableDirector? _originalDirector; 17 | private static int _iteration = 0; 18 | 19 | [HarmonyPrefix] 20 | [HarmonyPatch(typeof(MultiplayerIntroAnimationController), nameof(MultiplayerIntroAnimationController.PlayIntroAnimation))] 21 | private static void BeginPlayIntroAnimation(ref Action onCompleted, Action ____onCompleted, ref bool ____bindingFinished, ref PlayableDirector ____introPlayableDirector) 22 | { 23 | Plugin.Logger.Debug($"Creating intro PlayableDirector for iteration '{_iteration}'."); 24 | 25 | _originalDirector = ____introPlayableDirector; 26 | 27 | // Create new gameobject to play the animation after first 28 | if (_iteration != 0) 29 | { 30 | GameObject newPlayableGameObject = new GameObject(); 31 | ____introPlayableDirector = newPlayableGameObject.AddComponent(); 32 | ____introPlayableDirector.playableAsset = _originalDirector.playableAsset; 33 | 34 | // Cleanup gameobject 35 | onCompleted = () => { 36 | GameObject.Destroy(newPlayableGameObject); 37 | 38 | // Make sure old action happens by calling it 39 | ____onCompleted.Invoke(); 40 | }; 41 | } 42 | 43 | // Mute audio if animation is not first animation, so audio only plays once 44 | foreach (TrackAsset track in ((TimelineAsset)____introPlayableDirector.playableAsset).GetOutputTracks()) 45 | { 46 | track.muted = track is AudioTrack && _iteration != 0; 47 | } 48 | 49 | // Makes animator rebind to new playable 50 | ____bindingFinished = false; 51 | } 52 | 53 | [HarmonyPostfix] 54 | [HarmonyPatch(typeof(MultiplayerIntroAnimationController), nameof(MultiplayerIntroAnimationController.PlayIntroAnimation))] 55 | private static void EndPlayIntroAnimation(ref MultiplayerIntroAnimationController __instance, float maxDesiredIntroAnimationDuration, Action onCompleted, ref PlayableDirector ____introPlayableDirector, MultiplayerPlayersManager ____multiplayerPlayersManager) 56 | { 57 | _iteration++; 58 | ____introPlayableDirector = _originalDirector!; 59 | IEnumerable players = ____multiplayerPlayersManager.allActiveAtGameStartPlayers.Where(p => !p.isMe); 60 | if (_iteration < ((players.Count() + 3) / 4)) 61 | __instance.PlayIntroAnimation(maxDesiredIntroAnimationDuration, onCompleted); 62 | else 63 | _iteration = 0; // Reset 64 | } 65 | 66 | private static readonly MethodInfo _getActivePlayersMethod = AccessTools.PropertyGetter(typeof(MultiplayerPlayersManager), nameof(MultiplayerPlayersManager.allActiveAtGameStartPlayers)); 67 | 68 | [HarmonyTranspiler] 69 | [HarmonyPatch(typeof(MultiplayerIntroAnimationController), nameof(MultiplayerIntroAnimationController.BindTimeline))] 70 | private static IEnumerable PlayIntroPlayerCount(IEnumerable instructions) 71 | { 72 | var codes = instructions.ToList(); 73 | for (int i = 0; i < codes.Count; i++) 74 | { 75 | if (codes[i].Calls(_getActivePlayersMethod)) 76 | { 77 | codes[i] = new CodeInstruction(OpCodes.Callvirt, SymbolExtensions.GetMethodInfo(() => GetActivePlayersAttacher(null!))); 78 | } 79 | } 80 | return codes.AsEnumerable(); 81 | } 82 | 83 | private static IReadOnlyList GetActivePlayersAttacher(MultiplayerPlayersManager contract) 84 | { 85 | IEnumerable players = contract.allActiveAtGameStartPlayers.Where(p => !p.isMe); 86 | players = players.Skip(_iteration * 4).Take(4); 87 | if (_iteration == 0 && contract.allActiveAtGameStartPlayers.Any(p => p.isMe)) 88 | players.Append(contract.allActiveAtGameStartPlayers.First(p => p.isMe)); 89 | return players.ToList(); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/Serializable/DifficultyColors.cs: -------------------------------------------------------------------------------- 1 | using LiteNetLib.Utils; 2 | using static SongCore.Data.SongData; 3 | 4 | namespace MultiplayerCore.Beatmaps.Serializable 5 | { 6 | public class DifficultyColors : INetSerializable 7 | { 8 | public MapColor? ColorLeft; 9 | public MapColor? ColorRight; 10 | public MapColor? EnvColorLeft; 11 | public MapColor? EnvColorRight; 12 | public MapColor? EnvColorLeftBoost; 13 | public MapColor? EnvColorRightBoost; 14 | public MapColor? ObstacleColor; 15 | 16 | public bool AnyAreNotNull => ColorLeft != null || ColorRight != null || EnvColorLeft != null || 17 | EnvColorRight != null || EnvColorLeftBoost != null || EnvColorRightBoost != null || 18 | ObstacleColor != null; 19 | 20 | public DifficultyColors() 21 | { 22 | } 23 | 24 | public DifficultyColors(MapColor? colorLeft, MapColor? colorRight, MapColor? envColorLeft, MapColor? envColorRight, MapColor? envColorLeftBoost, MapColor? envColorRightBoost, MapColor? obstacleColor) 25 | { 26 | ColorLeft = colorLeft; 27 | ColorRight = colorRight; 28 | EnvColorLeft = envColorLeft; 29 | EnvColorRight = envColorRight; 30 | EnvColorLeftBoost = envColorLeftBoost; 31 | EnvColorRightBoost = envColorRightBoost; 32 | } 33 | 34 | public void Serialize(NetDataWriter writer) 35 | { 36 | byte colors = (byte)(ColorLeft != null ? 1 : 0); 37 | colors |= (byte)((ColorRight != null ? 1 : 0) << 1); 38 | colors |= (byte)((EnvColorLeft != null ? 1 : 0) << 2); 39 | colors |= (byte)((EnvColorRight != null ? 1 : 0) << 3); 40 | colors |= (byte)((EnvColorLeftBoost != null ? 1 : 0) << 4); 41 | colors |= (byte)((EnvColorRightBoost != null ? 1 : 0) << 5); 42 | colors |= (byte)((ObstacleColor != null ? 1 : 0) << 6); 43 | writer.Put(colors); 44 | 45 | if (ColorLeft != null) 46 | ((MapColorSerializable)ColorLeft).Serialize(writer); 47 | if (ColorRight != null) 48 | ((MapColorSerializable)ColorRight).Serialize(writer); 49 | if (EnvColorLeft != null) 50 | ((MapColorSerializable)EnvColorLeft).Serialize(writer); 51 | if (EnvColorRight != null) 52 | ((MapColorSerializable)EnvColorRight).Serialize(writer); 53 | if (EnvColorLeftBoost != null) 54 | ((MapColorSerializable)EnvColorLeftBoost).Serialize(writer); 55 | if (EnvColorRightBoost != null) 56 | ((MapColorSerializable)EnvColorRightBoost).Serialize(writer); 57 | if (ObstacleColor != null) 58 | ((MapColorSerializable)ObstacleColor).Serialize(writer); 59 | } 60 | 61 | public void Deserialize(NetDataReader reader) 62 | { 63 | var colors = reader.GetByte(); 64 | if ((colors & 0x1) != 0) 65 | ColorLeft = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 66 | if (((colors >> 1) & 0x1) != 0) 67 | ColorRight = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 68 | if (((colors >> 2) & 0x1) != 0) 69 | EnvColorLeft = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 70 | if (((colors >> 3) & 0x1) != 0) 71 | EnvColorRight = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 72 | if (((colors >> 4) & 0x1) != 0) 73 | EnvColorLeftBoost = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 74 | if (((colors >> 5) & 0x1) != 0) 75 | EnvColorRightBoost = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 76 | if (((colors >> 6) & 0x1) != 0) 77 | ObstacleColor = new MapColor(reader.GetFloat(), reader.GetFloat(), reader.GetFloat()); 78 | } 79 | 80 | public class MapColorSerializable : INetSerializable 81 | { 82 | public float r; 83 | public float g; 84 | public float b; 85 | 86 | public MapColorSerializable(float red, float green, float blue) 87 | { 88 | r = red; 89 | g = green; 90 | b = blue; 91 | } 92 | 93 | public void Serialize(NetDataWriter writer) 94 | { 95 | writer.Put(r); 96 | writer.Put(g); 97 | writer.Put(b); 98 | } 99 | 100 | public void Deserialize(NetDataReader reader) 101 | { 102 | r = reader.GetFloat(); 103 | g = reader.GetFloat(); 104 | b = reader.GetFloat(); 105 | } 106 | 107 | public static implicit operator MapColor(MapColorSerializable c) => new MapColor(c.r, c.g, c.b); 108 | public static explicit operator MapColorSerializable(MapColor c) => new MapColorSerializable(c.r, c.g, c.b); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/CustomLevelsPatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using HarmonyLib; 4 | using SiraUtil.Affinity; 5 | using SiraUtil.Logging; 6 | using UnityEngine.UI; 7 | 8 | namespace MultiplayerCore.Patchers 9 | { 10 | [HarmonyPatch] 11 | internal class CustomLevelsPatcher : IAffinity 12 | { 13 | private readonly NetworkConfigPatcher _networkConfig; 14 | private readonly SiraLog _logger; 15 | private bool? originalIncludeAllDifficulties = null; 16 | private string? _lastStatusUrl; 17 | 18 | internal CustomLevelsPatcher( 19 | NetworkConfigPatcher networkConfig, 20 | SiraLog logger) 21 | { 22 | _networkConfig = networkConfig; 23 | _logger = logger; 24 | } 25 | 26 | [AffinityPrefix] 27 | [AffinityPatch(typeof(MultiplayerLevelSelectionFlowCoordinator), "enableCustomLevels", AffinityMethodType.Getter)] 28 | private bool CustomLevelsEnabled(ref bool __result, SongPackMask ____songPackMask) 29 | { 30 | __result = 31 | _networkConfig.IsOverridingApi 32 | && ____songPackMask.Contains(new SongPackMask("custom_levelpack_CustomLevels")); 33 | _logger.Trace($"Custom levels enabled check songpackmask: '{____songPackMask.ToShortString()}' enables custom levels '{__result}'"); 34 | return false; 35 | } 36 | 37 | [HarmonyPrefix] 38 | [HarmonyPatch(typeof(LobbySetupViewController), nameof(LobbySetupViewController.SetPlayersMissingLevelText))] 39 | private static void SetPlayersMissingLevelText(LobbySetupViewController __instance, string playersMissingLevelText, ref Button ____startGameReadyButton) 40 | { 41 | if (!string.IsNullOrEmpty(playersMissingLevelText) && ____startGameReadyButton.interactable && __instance._isPartyOwner) 42 | __instance.SetStartGameEnabled(CannotStartGameReason.DoNotOwnSong); 43 | } 44 | 45 | [HarmonyPrefix] 46 | [HarmonyPatch(typeof(GameServerLobbyFlowCoordinator), 47 | nameof(GameServerLobbyFlowCoordinator.HandleMenuRpcManagerSetPlayersMissingEntitlementsToLevel))] 48 | private static bool HandleMenuRpcManagerSetPlayersMissingEntitlementsToLevel( 49 | GameServerLobbyFlowCoordinator __instance, 50 | PlayersMissingEntitlementsNetSerializable playersMissingEntitlements) 51 | { 52 | //if (__instance.isQuickStartServer) 53 | //{ 54 | __instance._playerIdsWithoutEntitlements.Clear(); 55 | __instance._playerIdsWithoutEntitlements.AddRange(playersMissingEntitlements.playersWithoutEntitlements); 56 | __instance.SetPlayersMissingLevelText(); 57 | return false; 58 | //} 59 | //return true; 60 | } 61 | 62 | [AffinityPrefix] 63 | [AffinityPatch(typeof(JoinQuickPlayViewController), nameof(JoinQuickPlayViewController.Setup))] 64 | private void SetupPre(JoinQuickPlayViewController __instance, ref BeatmapDifficultyDropdown ____beatmapDifficultyDropdown, QuickPlaySongPacksDropdown ____songPacksDropdown, QuickPlaySetupData quickPlaySetupData) 65 | { 66 | _logger.Trace("JoinQuickPlayViewController.Setup called"); 67 | _logger.Trace($"Check QPSD override: {quickPlaySetupData.hasOverride}"); 68 | // Ensure quickplay selection options are updated 69 | if (!originalIncludeAllDifficulties.HasValue) originalIncludeAllDifficulties = 70 | ____beatmapDifficultyDropdown.includeAllDifficulties; 71 | if (_networkConfig.IsOverridingApi) ____beatmapDifficultyDropdown.includeAllDifficulties = true; 72 | else ____beatmapDifficultyDropdown.includeAllDifficulties = originalIncludeAllDifficulties.Value; 73 | if (_lastStatusUrl != _networkConfig.MasterServerStatusUrl || _lastStatusUrl == null) 74 | { 75 | // Refresh difficulty dropdown 76 | ____beatmapDifficultyDropdown._beatmapDifficultyData = null; 77 | ____beatmapDifficultyDropdown.OnDestroy(); 78 | ____beatmapDifficultyDropdown.Start(); 79 | 80 | // Refresh song packs dropdown 81 | ____songPacksDropdown.SetOverrideSongPacks(quickPlaySetupData.quickPlayAvailablePacksOverride); // Ensure it's always set even when null 82 | ____songPacksDropdown._initialized = false; 83 | 84 | _lastStatusUrl = _networkConfig.MasterServerStatusUrl; 85 | } 86 | } 87 | 88 | [HarmonyPostfix] 89 | [HarmonyPatch(typeof(BeatmapDifficultyDropdown), nameof(BeatmapDifficultyDropdown.GetIdxForBeatmapDifficultyMask))] 90 | private static void GetIdxForBeatmapDifficultyMask(BeatmapDifficultyDropdown __instance, ref int __result) 91 | { 92 | if (__instance.includeAllDifficulties) __result = 0; 93 | } 94 | 95 | [AffinityPostfix] 96 | [AffinityPatch(typeof(QuickPlaySetupModel), nameof(QuickPlaySetupModel.IsQuickPlaySetupTaskValid))] 97 | private void IsQuickPlaySetupTaskValid(QuickPlaySetupModel __instance, ref bool __result, Task ____request, DateTime ____lastRequestTime) 98 | { 99 | if (_networkConfig.IsOverridingApi) __result = false; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/NoLevelSpectatorPatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using BGLib.Polyglot; 8 | using HarmonyLib; 9 | using IPA.Utilities; 10 | using MultiplayerCore.Beatmaps; 11 | using MultiplayerCore.Beatmaps.Abstractions; 12 | using MultiplayerCore.Beatmaps.Providers; 13 | using MultiplayerCore.Objects; 14 | using MultiplayerCore.Patchers; 15 | 16 | namespace MultiplayerCore.Patches 17 | { 18 | [HarmonyPatch] 19 | internal class NoLevelSpectatorPatch 20 | { 21 | internal static MpBeatmapLevelProvider? _mpBeatmapLevelProvider; 22 | internal static MpPlayersDataModel? _playersDataModel; 23 | 24 | [HarmonyPrefix] 25 | [HarmonyPatch(typeof(LobbyGameStateController), nameof(LobbyGameStateController.StartMultiplayerLevel))] 26 | internal static bool LobbyGameStateController_StartMultiplayerLevel(LobbyGameStateController __instance, ILevelGameplaySetupData gameplaySetupData, IBeatmapLevelData beatmapLevelData, Action beforeSceneSwitchCallback) 27 | { 28 | _playersDataModel = __instance._lobbyPlayersDataModel as MpPlayersDataModel; 29 | _mpBeatmapLevelProvider = _playersDataModel?._beatmapLevelProvider; 30 | 31 | if (_playersDataModel == null || _mpBeatmapLevelProvider == null) 32 | { 33 | Plugin.Logger.Critical($"Missing custom MpPlayersDataModel or MpBeatmapLevelProvider, cannot continue, returning..."); 34 | return false; 35 | } 36 | 37 | var levelHash = Utilities.HashForLevelID(gameplaySetupData.beatmapKey.levelId); 38 | if (gameplaySetupData != null && beatmapLevelData == null && !string.IsNullOrWhiteSpace(levelHash)) 39 | { 40 | Plugin.Logger.Info($"No LevelData for custom level {levelHash} running patch for spectator"); 41 | var packet = _playersDataModel.FindLevelPacket(levelHash!); 42 | Task? levelTask = null; 43 | if (packet != null) 44 | { 45 | #pragma warning disable CS8619 // Nullability of reference types in value doesn't match target types. 46 | levelTask = Task.FromResult(_mpBeatmapLevelProvider.GetBeatmapFromPacket(packet)); 47 | #pragma warning restore CS8619 48 | } 49 | 50 | if (levelTask == null) levelTask = _mpBeatmapLevelProvider.GetBeatmap(levelHash!); 51 | __instance.countdownStarted = false; 52 | __instance.StopListeningToGameStart(); // Ensure we stop listening for the start event while we run our start task 53 | levelTask.ContinueWith(beatmapTask => 54 | { 55 | if (__instance.countdownStarted) return; // Another countdown has started, don't start the level 56 | 57 | BeatmapLevel? beatmapLevel = beatmapTask.Result?.MakeBeatmapLevel(gameplaySetupData.beatmapKey, 58 | _mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(levelHash!)); 59 | if (beatmapLevel == null) 60 | beatmapLevel = new NoInfoBeatmapLevel(levelHash!).MakeBeatmapLevel(gameplaySetupData.beatmapKey, 61 | _mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(levelHash!)); 62 | 63 | __instance._menuTransitionsHelper.StartMultiplayerLevel("Multiplayer", gameplaySetupData.beatmapKey, beatmapLevel, beatmapLevelData, 64 | __instance._playerDataModel.playerData.colorSchemesSettings.GetOverrideColorScheme(), gameplaySetupData.gameplayModifiers, 65 | __instance._playerDataModel.playerData.playerSpecificSettings, null, Localization.Get("BUTTON_MENU"), false, 66 | beforeSceneSwitchCallback, 67 | __instance.HandleMultiplayerLevelDidFinish, 68 | __instance.HandleMultiplayerLevelDidDisconnect 69 | ); 70 | }); 71 | return false; 72 | } 73 | Plugin.Logger.Debug("LevelData present running orig"); 74 | return true; 75 | } 76 | 77 | [HarmonyPostfix] 78 | [HarmonyPatch(typeof(MultiplayerResultsViewController), nameof(MultiplayerResultsViewController.Init))] 79 | internal static void MultiplayerResultsViewController_Init(MultiplayerResultsViewController __instance, BeatmapKey beatmapKey) 80 | { 81 | var hash = Utilities.HashForLevelID(beatmapKey.levelId); 82 | if (NoLevelSpectatorPatch._mpBeatmapLevelProvider != null && !string.IsNullOrWhiteSpace(hash) && 83 | SongCore.Loader.GetLevelByHash(hash!) == null) 84 | { 85 | IPA.Utilities.Async.UnityMainThreadTaskScheduler.Factory.StartNew(async () => 86 | { 87 | var packet = NoLevelSpectatorPatch._playersDataModel?.FindLevelPacket(hash!); 88 | BeatmapLevel? beatmapLevel = packet != null ? NoLevelSpectatorPatch._mpBeatmapLevelProvider.GetBeatmapFromPacket(packet)? 89 | .MakeBeatmapLevel(beatmapKey, NoLevelSpectatorPatch._mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(hash!)) : null; 90 | if (beatmapLevel == null) beatmapLevel = (await NoLevelSpectatorPatch._mpBeatmapLevelProvider.GetBeatmap(hash!))? 91 | .MakeBeatmapLevel(beatmapKey, NoLevelSpectatorPatch._mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(hash!)); 92 | if (beatmapLevel == null) 93 | beatmapLevel = new NoInfoBeatmapLevel(hash!).MakeBeatmapLevel(beatmapKey, NoLevelSpectatorPatch._mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(hash!)); 94 | Plugin.Logger.Trace($"Calling Setup with level type: {beatmapLevel.GetType().Name}, beatmapCharacteristic type: {beatmapKey.beatmapCharacteristic.GetType().Name}, difficulty type: {beatmapKey.difficulty.GetType().Name} "); 95 | __instance._levelBar.Setup(beatmapLevel, beatmapKey.difficulty, beatmapKey.beatmapCharacteristic); 96 | }); 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/BeatmapSelectionViewPatcher.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using MultiplayerCore.Beatmaps.Abstractions; 3 | using MultiplayerCore.Beatmaps.Providers; 4 | using MultiplayerCore.Objects; 5 | using SiraUtil.Affinity; 6 | using System; 7 | using System.Collections; 8 | using System.Collections.Generic; 9 | using System.Reflection; 10 | using MultiplayerCore.Beatmaps.Packets; 11 | 12 | namespace MultiplayerCore.Patchers 13 | { 14 | internal class BeatmapSelectionViewPatcher : IAffinity 15 | { 16 | private MpPlayersDataModel? _mpPlayersDataModel; 17 | private MpBeatmapLevelProvider _mpBeatmapLevelProvider; 18 | private BeatmapLevelsModel _beatmapLevelsModel; 19 | 20 | BeatmapSelectionViewPatcher(ILobbyPlayersDataModel playersDataModel, MpBeatmapLevelProvider mpBeatmapLevelProvider, BeatmapLevelsModel beatmapLevelsModel) 21 | { 22 | _mpPlayersDataModel = playersDataModel as MpPlayersDataModel; 23 | _mpBeatmapLevelProvider = mpBeatmapLevelProvider; 24 | _beatmapLevelsModel = beatmapLevelsModel; 25 | } 26 | 27 | [AffinityPrefix] 28 | [AffinityPatch(typeof(EditableBeatmapSelectionView), nameof(EditableBeatmapSelectionView.SetBeatmap))] 29 | public bool EditableBeatmapSelectionView_SetBeatmap(EditableBeatmapSelectionView __instance, in BeatmapKey beatmapKey) 30 | { 31 | if (_mpPlayersDataModel == null) return false; 32 | if (!beatmapKey.IsValid()) return true; 33 | if (_beatmapLevelsModel.GetBeatmapLevel(beatmapKey.levelId) != null) return true; 34 | 35 | var levelHash = Utilities.HashForLevelID(beatmapKey.levelId); 36 | if (string.IsNullOrWhiteSpace(levelHash)) return true; 37 | 38 | var packet = _mpPlayersDataModel.FindLevelPacket(levelHash!); 39 | 40 | __instance.StartCoroutine(SetBeatmapCoroutine(__instance, beatmapKey, levelHash!, packet)); 41 | return false; 42 | } 43 | 44 | [AffinityPrefix] 45 | [AffinityPatch(typeof(BeatmapSelectionView), nameof(BeatmapSelectionView.SetBeatmap))] 46 | public bool BeatmapSelectionView_SetBeatmap(ref BeatmapSelectionView __instance, in BeatmapKey beatmapKey) 47 | { 48 | if (_mpPlayersDataModel == null) return false; 49 | if (!beatmapKey.IsValid()) return true; 50 | if (_beatmapLevelsModel.GetBeatmapLevel(beatmapKey.levelId) != null) return true; 51 | 52 | var levelHash = Utilities.HashForLevelID(beatmapKey.levelId); 53 | if (string.IsNullOrWhiteSpace(levelHash)) return true; 54 | 55 | var packet = _mpPlayersDataModel.FindLevelPacket(levelHash!); 56 | 57 | __instance.StartCoroutine(SetBeatmapCoroutine(__instance, beatmapKey, levelHash!, packet)); 58 | return false; 59 | } 60 | 61 | IEnumerator SetBeatmapCoroutine(BeatmapSelectionView instance, BeatmapKey key, string levelHash, MpBeatmapPacket? packet = null) 62 | { 63 | BeatmapLevel? level; 64 | if (packet != null) 65 | { 66 | level = _mpBeatmapLevelProvider.GetBeatmapFromPacket(packet).MakeBeatmapLevel(key, 67 | _mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(packet.levelHash)); 68 | } 69 | else 70 | { 71 | var levelTask = _mpBeatmapLevelProvider.GetBeatmap(levelHash!); 72 | yield return IPA.Utilities.Async.Coroutines.WaitForTask(levelTask); 73 | 74 | level = levelTask.Result?.MakeBeatmapLevel(key, 75 | _mpBeatmapLevelProvider.MakeBeatSaverPreviewMediaData(levelHash!)); 76 | } 77 | 78 | if (level != null) 79 | { 80 | if (instance is EditableBeatmapSelectionView editView) editView._clearButton.gameObject.SetActive(editView.showClearButton); 81 | instance._noLevelText.enabled = false; 82 | instance._levelBar.hide = false; 83 | 84 | 85 | Plugin.Logger.Debug($"Calling Setup with level type: {level.GetType().Name}, beatmapCharacteristic type: {key.beatmapCharacteristic.GetType().Name}, difficulty type: {key.difficulty.GetType().Name} "); 86 | 87 | instance._levelBar.Setup(level, key.difficulty, key.beatmapCharacteristic); 88 | } 89 | else 90 | { 91 | Plugin.Logger.Error($"Could not get level info for level {levelHash}"); 92 | if (instance is EditableBeatmapSelectionView editView) editView._clearButton.gameObject.SetActive(false); 93 | instance._noLevelText.enabled = true; 94 | instance._levelBar.hide = true; 95 | } 96 | } 97 | } 98 | 99 | public static class PacketExt 100 | { 101 | public static BeatmapLevel MakeBeatmapLevel(this MpBeatmap mpBeatmap, in BeatmapKey key, IPreviewMediaData previewMediaData) 102 | { 103 | var dict = new Dictionary<(BeatmapCharacteristicSO, BeatmapDifficulty), BeatmapBasicData> 104 | { 105 | [(key.beatmapCharacteristic, key.difficulty)] = new BeatmapBasicData( 106 | 0, 107 | 0, 108 | EnvironmentName.Empty, 109 | null, 110 | 0, 111 | 0, 112 | 0, 113 | 0, 114 | new[] { mpBeatmap.LevelAuthorName }, 115 | Array.Empty() 116 | ) 117 | }; 118 | 119 | return new BeatmapLevel( 120 | 0, 121 | false, 122 | mpBeatmap.LevelID, 123 | mpBeatmap.SongName, 124 | mpBeatmap.SongAuthorName, 125 | mpBeatmap.SongSubName, 126 | new[] { mpBeatmap.LevelAuthorName }, 127 | Array.Empty(), 128 | mpBeatmap.BeatsPerMinute, 129 | -6.0f, 130 | 0, 131 | 0, 132 | 0, 133 | mpBeatmap.SongDuration, 134 | PlayerSensitivityFlag.Safe, 135 | previewMediaData, 136 | dict 137 | ); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /MultiplayerCore/Patches/MultiplayerUnavailableReasonPatches.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using Hive.Versioning; 3 | using IPA.Loader; 4 | using IPA.Utilities; 5 | using MultiplayerCore.Models; 6 | 7 | namespace MultiplayerCore.Patches 8 | { 9 | [HarmonyPatch] 10 | internal class MultiplayerUnavailableReasonPatches 11 | { 12 | private static string _requiredMod = string.Empty; 13 | private static string _requiredVersion = string.Empty; 14 | private static string _maximumBsVersion = string.Empty; 15 | 16 | [HarmonyPrefix] 17 | [HarmonyPatch(typeof(MultiplayerUnavailableReasonMethods), nameof(MultiplayerUnavailableReasonMethods.TryGetMultiplayerUnavailableReason))] 18 | private static bool TryGetMultiplayerUnavailableReasonPrefix(MultiplayerStatusData data, out MultiplayerUnavailableReason reason, ref bool __result) 19 | { 20 | reason = (MultiplayerUnavailableReason)0; 21 | if (data is MpStatusData mpData) 22 | { 23 | if (mpData.requiredMods != null) 24 | { 25 | foreach (var requiredMod in mpData.requiredMods) 26 | { 27 | var metadata = PluginManager.GetPluginFromId(requiredMod.id); 28 | if (metadata == null && !requiredMod.required) 29 | // Optional mod is not installed 30 | continue; 31 | 32 | var requiredVersion = new Version(requiredMod.version); 33 | if (metadata != null && metadata.HVersion >= requiredVersion) 34 | // Mod is installed and up to date 35 | continue; 36 | 37 | reason = (MultiplayerUnavailableReason)5; 38 | _requiredMod = requiredMod.id; 39 | _requiredVersion = requiredMod.version; 40 | __result = true; 41 | return false; 42 | } 43 | } 44 | 45 | if (mpData.maximumAppVersion != null) 46 | { 47 | var version = new AlmostVersion(mpData.maximumAppVersion); 48 | if (UnityGame.GameVersion > version) 49 | { 50 | reason = (MultiplayerUnavailableReason)6; 51 | _maximumBsVersion = mpData.maximumAppVersion; 52 | __result = true; 53 | return false; 54 | } 55 | } 56 | } 57 | return true; 58 | } 59 | 60 | [HarmonyPrefix] 61 | [HarmonyPatch(typeof(MultiplayerUnavailableReasonMethods), nameof(MultiplayerUnavailableReasonMethods.LocalizedKey))] 62 | private static bool LocalizeMultiplayerUnavailableReason(MultiplayerUnavailableReason multiplayerUnavailableReason, ref string __result) 63 | { 64 | if (multiplayerUnavailableReason == (MultiplayerUnavailableReason)5) 65 | { 66 | var metadata = PluginManager.GetPluginFromId(_requiredMod); 67 | __result = $"Multiplayer Unavailable\nMod {metadata.Name} is missing or out of date\nPlease install version {_requiredVersion} or newer"; 68 | return false; 69 | } 70 | if (multiplayerUnavailableReason == (MultiplayerUnavailableReason)6) 71 | { 72 | __result = $"Multiplayer Unavailable\nBeat Saber version is too new\nMaximum version: {_maximumBsVersion}\nCurrent version: {UnityGame.GameVersion}"; 73 | return false; 74 | } 75 | return true; 76 | } 77 | 78 | [HarmonyPrefix] 79 | [HarmonyPatch(typeof(ConnectionFailedReasonMethods), nameof(ConnectionFailedReasonMethods.LocalizedKey))] 80 | private static bool LocalizeConnectionFailedReason(ConnectionFailedReason connectionFailedReason, 81 | ref string __result) 82 | { 83 | switch ((byte)connectionFailedReason) 84 | { 85 | case 50: 86 | { 87 | //__result = "CONNECTION_FAILED_VERSION_MISMATCH"; // Would show an "Update the game message" 88 | __result = 89 | $"Game Version Unknown\n" + 90 | $"Your game version was not within any version ranges known by the server"; 91 | return false; 92 | } 93 | case 51: 94 | { 95 | __result = 96 | $"Game Version Too Old\n" + 97 | $"Your game version is below the supported version range of the lobby\n" + 98 | $"You either need to update or the lobby host needs to downgrade their game"; 99 | return false; 100 | } 101 | case 52: 102 | { 103 | __result = 104 | $"Game Version Too New\n" + 105 | $"Your game version is above the supported version range of the lobby\n" + 106 | $"You either need to downgrade or the lobby host needs to update their game"; 107 | return false; 108 | } 109 | } 110 | return true; 111 | } 112 | 113 | [HarmonyPrefix] 114 | [HarmonyPatch(typeof(MultiplayerPlacementErrorCodeMethods), nameof(MultiplayerPlacementErrorCodeMethods.ToConnectionFailedReason))] 115 | private static bool ToConnectionFailedReason(MultiplayerPlacementErrorCode errorCode, 116 | ref ConnectionFailedReason __result) 117 | { 118 | Plugin.Logger.Debug($"Got MPEC-{errorCode}"); 119 | if ((int)errorCode >= 50) 120 | { 121 | __result = (ConnectionFailedReason)errorCode; 122 | return false; 123 | } 124 | 125 | return true; 126 | } 127 | 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /MultiplayerCore/Beatmaps/Packets/MpBeatmapPacket.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using LiteNetLib.Utils; 3 | using MultiplayerCore.Beatmaps.Abstractions; 4 | using MultiplayerCore.Beatmaps.Serializable; 5 | using MultiplayerCore.Networking.Abstractions; 6 | using static SongCore.Data.SongData; 7 | 8 | namespace MultiplayerCore.Beatmaps.Packets 9 | { 10 | public class MpBeatmapPacket : MpPacket 11 | { 12 | public string levelHash = null!; 13 | public string songName = null!; 14 | public string songSubName = null!; 15 | public string songAuthorName = null!; 16 | public string levelAuthorName = null!; 17 | public float beatsPerMinute; 18 | public float songDuration; 19 | 20 | public string characteristicName = null!; 21 | public BeatmapDifficulty difficulty; 22 | 23 | public Dictionary requirements = new(); 24 | public Dictionary mapColors = new(); 25 | public Contributor[] contributors = null!; 26 | 27 | public MpBeatmapPacket() { } 28 | 29 | public MpBeatmapPacket(MpBeatmap beatmap, BeatmapKey beatmapKey) 30 | { 31 | levelHash = Utilities.HashForLevelID(beatmap.LevelID) ?? ""; 32 | songName = beatmap.SongName; 33 | songSubName = beatmap.SongSubName; 34 | songAuthorName = beatmap.SongAuthorName; 35 | levelAuthorName = beatmap.LevelAuthorName; 36 | beatsPerMinute = beatmap.BeatsPerMinute; 37 | songDuration = beatmap.SongDuration; 38 | characteristicName = beatmapKey.beatmapCharacteristic.serializedName; 39 | difficulty = beatmapKey.difficulty; 40 | if (beatmap.Requirements.TryGetValue(characteristicName, out var requirementSet)) 41 | requirements = requirementSet; 42 | contributors = beatmap.Contributors!; 43 | } 44 | 45 | public override void Serialize(NetDataWriter writer) 46 | { 47 | writer.Put(levelHash); 48 | writer.Put(songName); 49 | writer.Put(songSubName); 50 | writer.Put(songAuthorName); 51 | writer.Put(levelAuthorName); 52 | writer.Put(beatsPerMinute); 53 | writer.Put(songDuration); 54 | 55 | writer.Put(characteristicName); 56 | writer.Put((uint)difficulty); 57 | 58 | writer.Put((byte)requirements.Count); 59 | foreach (var difficulty in requirements) 60 | { 61 | writer.Put((byte)difficulty.Key); 62 | writer.Put((byte)difficulty.Value.Length); 63 | foreach (var requirement in difficulty.Value) 64 | writer.Put(requirement); 65 | } 66 | 67 | if (contributors != null) 68 | { 69 | writer.Put((byte)contributors.Length); 70 | foreach (var contributor in contributors) 71 | { 72 | writer.Put(contributor._role); 73 | writer.Put(contributor._name); 74 | writer.Put(contributor._iconPath); 75 | } 76 | } 77 | else 78 | writer.Put((byte)0); 79 | 80 | writer.Put((byte)mapColors.Count); 81 | foreach (var difficulty in mapColors) 82 | { 83 | writer.Put((byte)difficulty.Key); 84 | difficulty.Value.Serialize(writer); 85 | } 86 | } 87 | 88 | public override void Deserialize(NetDataReader reader) 89 | { 90 | levelHash = reader.GetString(); 91 | songName = reader.GetString(); 92 | songSubName = reader.GetString(); 93 | songAuthorName = reader.GetString(); 94 | levelAuthorName = reader.GetString(); 95 | beatsPerMinute = reader.GetFloat(); 96 | songDuration = reader.GetFloat(); 97 | 98 | characteristicName = reader.GetString(); 99 | difficulty = (BeatmapDifficulty)reader.GetUInt(); 100 | 101 | try 102 | { 103 | var difficultyCount = reader.GetByte(); 104 | for (int i = 0; i < difficultyCount; i++) 105 | { 106 | var difficulty = (BeatmapDifficulty)reader.GetByte(); 107 | var requirementCount = reader.GetByte(); 108 | string[] reqsForDifficulty = new string[requirementCount]; 109 | for (int j = 0; j < requirementCount; j++) 110 | reqsForDifficulty[j] = reader.GetString(); 111 | requirements[difficulty] = reqsForDifficulty; 112 | } 113 | 114 | var contributorCount = reader.GetByte(); 115 | contributors = new Contributor[contributorCount]; 116 | for (int i = 0; i < contributorCount; i++) 117 | contributors[i] = new Contributor 118 | { 119 | _role = reader.GetString(), 120 | _name = reader.GetString(), 121 | _iconPath = reader.GetString() 122 | }; 123 | 124 | var colorCount = reader.GetByte(); 125 | for (int i = 0; i < colorCount; i++) 126 | { 127 | var difficulty = (BeatmapDifficulty)reader.GetByte(); 128 | var colors = new DifficultyColors(); 129 | colors.Deserialize(reader); 130 | mapColors[difficulty] = colors; 131 | } 132 | } 133 | catch 134 | { 135 | Plugin.Logger.Warn($"Player using old version of MultiplayerCore, not all info may be available."); 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiplayerCore (Steam/PC-Only) [![Build](https://github.com/Goobwabber/MultiplayerCore/workflows/Build/badge.svg?event=push)](https://github.com/Goobwabber/MultiplayerCore/actions?query=workflow%3ABuild+branch%3Amain) 2 | A Beat Saber mod that implements core custom multiplayer functionality. This is a core mod and doesn't really add anything to the game without a custom server to support it's features. I Recommend [BeatTogether](https://github.com/BeatTogether/BeatTogether). 3 | 4 | ## Installation 5 | 1. Ensure you have the [required mods](https://github.com/Goobwabber/MultiplayerCore#requirements). 6 | 2. Download the `MultiplayerCore` file listed under `Assets` **[Here](https://github.com/Goobwabber/MultiplayerCore/releases)**. 7 | * Optionally, you can get a development build by downloading the file listed under `Artifacts` **[Here](https://github.com/Goobwabber/MultiplayerCore/actions?query=workflow%3ABuild+branch%3Amain)** (pick the topmost successful build). 8 | * You must be logged into GitHub to download a development build. 9 | 3. Extract the zip file to your Beat Saber game directory (the one `Beat Saber.exe` is in). 10 | * The `MultiplayerCore.dll` (and `MultiplayerCore.pdb` if it exists) should end up in your `Plugins` folder (**NOT** the one in `Beat Saber_Data`). 11 | 4. **Optional**: Edit `Beat Saber IPA.json` (in your `UserData` folder) and change `Debug` -> `ShowCallSource` to `true`. This will enable BSIPA to get file and line numbers from the `PDB` file where errors occur, which is very useful when reading the log files. This may have a *slight* impact on performance. 12 | 13 | Lastly, check out [other mods](https://github.com/Goobwabber/MultiplayerCore#related-mods) that depend on MultiplayerCore! 14 | 15 | ## Requirements 16 | These can be downloaded from [BeatMods](https://beatmods.com/#/mods) or using Mod Assistant. 17 | * BSIPA v4.1.4+ 18 | * SongCore v3.9.3+ 19 | * BeatSaverSharp v3.0.1+ 20 | * SiraUtil 3.0.0+ 21 | * BeatSaberMarkupLanguage 1.6.3+ 22 | 23 | ## Reporting Issues 24 | * The best way to report issues is to click on the `Issues` tab at the top of the GitHub page. This allows any contributor to see the problem and attempt to fix it, and others with the same issue can contribute more information. **Please try the troubleshooting steps before reporting the issues listed there. Please only report issues after using the latest build, your problem may have already been fixed.** 25 | * Include in your issue: 26 | * A detailed explanation of your problem (you can also attach videos/screenshots) 27 | * **Important**: The log file from the game session the issue occurred (restarting the game creates a new log file). 28 | * The log file can be found at `Beat Saber\Logs\_latest.log` (`Beat Saber` being the folder `Beat Saber.exe` is in). 29 | * If you ask for help on Discord, at least include your `_latest.log` file in your help request. 30 | 31 | ## For Other Modders 32 | How do I send and receive packets??? 33 | ok just do this 34 | oh yeah also you have to register the packet before you send it!!!! 35 | ```cs 36 | // ok cool wanna make a packet? 37 | public class WhateverTheFuckPacket : MpPacket { 38 | public override void Serialize(NetDataWriter writer) 39 | { 40 | // write data into packet 41 | } 42 | 43 | public override void Deserialize(NetDataReader reader) 44 | { 45 | // read data from packet 46 | } 47 | } 48 | 49 | // ok cool wanna send it and receive it now? 50 | public class WhateverTheFuckManager : IInitializable, IDisposable 51 | { 52 | [Inject] 53 | private readonly MpPacketSerializer _packetSerializer; 54 | 55 | public void Initialize() 56 | => _packetSerializer.RegisterCallback(HandlePacket); 57 | 58 | public void Dispose() 59 | => _packetSerializer.UnregisterCallback(); 60 | 61 | public void Send() 62 | { 63 | // send (you can do this from anywhere really that the game can handle it but i prefer to do it here) 64 | _multiplayerSessionManager.Send(new WhateverTheFuckPacket()); 65 | } 66 | 67 | public void HandlePacket(WhateverTheFuckPacket packet, IConnectedPlayer player) 68 | { 69 | // handle that shit fam 70 | } 71 | } 72 | ``` 73 | 74 | ## Contributing 75 | Anyone can feel free to contribute bug fixes or enhancements to MultiplayerCore. Please keep in mind that this mod's purpose is to implement core functionality of modded multiplayer, so we will likely not be accepting enhancements that fall out of that scope. GitHub Actions for Pull Requests made from GitHub accounts that don't have direct access to the repository will fail. This is normal because the Action requires a `Secret` to download dependencies. 76 | ### Building 77 | Visual Studio 2022 with the [BeatSaberModdingTools](https://github.com/Zingabopp/BeatSaberModdingTools) extension is the recommended development environment. 78 | 1. Check out the repository 79 | 2. Open `MultiplayerCore.sln` 80 | 3. Right-click the `MultiplayerCore` project, go to `Beat Saber Modding Tools` -> `Set Beat Saber Directory` 81 | * This assumes you have already set the directory for your Beat Saber game folder in `Extensions` -> `Beat Saber Modding Tools` -> `Settings...` 82 | * If you do not have the BeatSaberModdingTools extension, you will need to manually create a `MultiplayerCore.csproj.user` file to set the location of your game install. An example is showing below. 83 | 4. The project should now build. 84 | 85 | **Example csproj.user File:** 86 | ```xml 87 | 88 | 89 | 90 | Full\Path\To\Beat Saber 91 | 92 | 93 | ``` 94 | ## Donate 95 | You can support development of MultiplayerCore by donating at the following links: 96 | * https://www.patreon.com/goobwabber 97 | * https://ko-fi.com/goobwabber 98 | 99 | ## Related Mods 100 | * [MultiplayerExtensions](https://github.com/Goobwabber/MultiplayerExtensions) 101 | * [BeatTogether](https://github.com/BeatTogether/BeatTogether) 102 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/GameServerPlayerTableCellPatcher.cs: -------------------------------------------------------------------------------- 1 | using SiraUtil.Affinity; 2 | using System.Collections; 3 | using System.Threading.Tasks; 4 | using MultiplayerCore.Objects; 5 | using BGLib.Polyglot; 6 | using MultiplayerCore.Beatmaps.Abstractions; 7 | using MultiplayerCore.Beatmaps.Providers; 8 | 9 | namespace MultiplayerCore.Patchers 10 | { 11 | internal class GameServerPlayerTableCellPatcher : IAffinity 12 | { 13 | private MpPlayersDataModel? _mpPlayersDataModel; 14 | private MpBeatmapLevelProvider _mpBeatmapLevelProvider; 15 | private ICoroutineStarter _sharedCoroutineStarter; 16 | 17 | GameServerPlayerTableCellPatcher(ILobbyPlayersDataModel playersDataModel, MpBeatmapLevelProvider mpBeatmapLevelProvider, ICoroutineStarter sharedCoroutineStarter) 18 | { 19 | _mpPlayersDataModel = playersDataModel as MpPlayersDataModel; 20 | _mpBeatmapLevelProvider = mpBeatmapLevelProvider; 21 | _sharedCoroutineStarter = sharedCoroutineStarter; 22 | } 23 | 24 | [AffinityPrefix] 25 | [AffinityPatch(typeof(GameServerPlayerTableCell), nameof(GameServerPlayerTableCell.SetData))] 26 | bool GameServerPlayerTableCell_SetData(ref GameServerPlayerTableCell __instance, IConnectedPlayer connectedPlayer, ILobbyPlayerData playerData, bool hasKickPermissions, bool allowSelection, Task? getLevelEntitlementTask) 27 | { 28 | __instance._playerNameText.text = connectedPlayer.userName; 29 | __instance._localPlayerBackgroundImage.enabled = connectedPlayer.isMe; 30 | if (!playerData.isReady && playerData.isActive && !playerData.isPartyOwner) 31 | { 32 | __instance._statusImageView.enabled = false; 33 | } 34 | else 35 | { 36 | __instance._statusImageView.enabled = true; 37 | 38 | var statusView = __instance._statusImageView; 39 | if (playerData.isPartyOwner) statusView.sprite = __instance._hostIcon; 40 | else if (playerData.isActive) statusView.sprite = __instance._readyIcon; 41 | else statusView.sprite = __instance._spectatingIcon; 42 | } 43 | 44 | _sharedCoroutineStarter.StartCoroutine(SetDataCoroutine(__instance, connectedPlayer, playerData, hasKickPermissions, 45 | allowSelection, getLevelEntitlementTask)); 46 | return false; 47 | } 48 | IEnumerator SetDataCoroutine(GameServerPlayerTableCell instance, IConnectedPlayer connectedPlayer, ILobbyPlayerData playerData, bool hasKickPermissions, bool allowSelection, Task? getLevelEntitlementTask) 49 | { 50 | var key = playerData.beatmapKey; 51 | Plugin.Logger.Debug($"Start SetDataCoroutine with key {key.levelId} diff {key.difficulty.Name()}"); 52 | bool displayLevelText = key.IsValid(); 53 | if (displayLevelText) 54 | { 55 | var level = instance._beatmapLevelsModel.GetBeatmapLevel(key.levelId); 56 | var levelHash = Utilities.HashForLevelID(key.levelId); 57 | instance._suggestedLevelText.text = level?.songName; 58 | displayLevelText = level != null; 59 | 60 | if (level == null && _mpPlayersDataModel != null && !string.IsNullOrEmpty(levelHash)) // we didn't have the level, but we can attempt to get the packet 61 | { 62 | var packet = _mpPlayersDataModel.FindLevelPacket(levelHash!); 63 | instance._suggestedLevelText.text = packet?.songName; 64 | 65 | Task? mpLevelTask = null; 66 | if (packet == null) 67 | { 68 | Plugin.Logger.Debug("Could not find packet, trying beatsaver"); 69 | mpLevelTask = _mpBeatmapLevelProvider.GetBeatmapFromBeatSaver(levelHash!); 70 | yield return IPA.Utilities.Async.Coroutines.WaitForTask(mpLevelTask); 71 | Plugin.Logger.Debug($"Task finished SongName={mpLevelTask.Result?.SongName}"); 72 | instance._suggestedLevelText.text = mpLevelTask.Result?.SongName; 73 | } 74 | 75 | displayLevelText = packet != null || mpLevelTask?.Result != null; 76 | Plugin.Logger.Debug($"Will display level text? {displayLevelText}"); 77 | } 78 | 79 | instance._suggestedCharacteristicIcon.sprite = key.beatmapCharacteristic.icon; 80 | instance._suggestedDifficultyText.text = key.difficulty.ShortName(); 81 | } else Plugin.Logger.Debug("Player key was not valid, disabling"); 82 | SetLevelFoundValues(instance, displayLevelText); 83 | bool anyModifiers = !(playerData?.gameplayModifiers?.IsWithoutModifiers() ?? true); 84 | instance._suggestedModifiersList.gameObject.SetActive(anyModifiers); 85 | instance._emptySuggestedModifiersText.gameObject.SetActive(!anyModifiers); 86 | 87 | if (anyModifiers) 88 | { 89 | var modifiers = instance._gameplayModifiers.CreateModifierParamsList(playerData.gameplayModifiers); 90 | instance._emptySuggestedModifiersText.gameObject.SetActive(modifiers.Count == 0); 91 | if (modifiers.Count > 0) 92 | { 93 | instance._suggestedModifiersList.SetData(modifiers.Count, (int id, GameplayModifierInfoListItem listItem) => listItem.SetModifier(modifiers[id], false)); 94 | } 95 | } 96 | 97 | instance._useModifiersButton.interactable = !connectedPlayer.isMe && anyModifiers && allowSelection; 98 | instance._kickPlayerButton.interactable = !connectedPlayer.isMe && hasKickPermissions && allowSelection; 99 | instance._mutePlayerButton.gameObject.SetActive(false); 100 | if (getLevelEntitlementTask != null && !connectedPlayer.isMe) 101 | { 102 | instance._useBeatmapButtonHoverHint.text = Localization.Get("LABEL_CANT_START_GAME_DO_NOT_OWN_SONG"); 103 | instance.SetBeatmapUseButtonEnabledAsync(getLevelEntitlementTask); 104 | yield break; 105 | } 106 | 107 | instance._useBeatmapButton.interactable = false; 108 | instance._useBeatmapButtonHoverHint.enabled = false; 109 | 110 | } 111 | 112 | void SetLevelFoundValues(GameServerPlayerTableCell __instance, bool displayLevelText) 113 | { 114 | __instance._suggestedLevelText.gameObject.SetActive(displayLevelText); 115 | __instance._suggestedCharacteristicIcon.gameObject.SetActive(displayLevelText); 116 | __instance._suggestedDifficultyText.gameObject.SetActive(displayLevelText); 117 | __instance._emptySuggestedLevelText.gameObject.SetActive(!displayLevelText); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/PlayerCountPatcher.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using SiraUtil.Affinity; 3 | using SiraUtil.Logging; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection.Emit; 7 | using UnityEngine; 8 | 9 | namespace MultiplayerCore.Patchers 10 | { 11 | [HarmonyPatch] 12 | public class PlayerCountPatcher : IAffinity 13 | { 14 | /// 15 | /// The minimum amount of players you can create a lobby with. 16 | /// Defaults to 2. 17 | /// 18 | public int MinPlayers { get; set; } = 2; 19 | 20 | /// 21 | /// The maximum amount of players you can create a lobby with. 22 | /// Uses the value from an injected . 23 | /// 24 | public int MaxPlayers => _networkConfig.maxPartySize; 25 | 26 | /// 27 | /// Whether to add an extra empty player space when laying out a lobby with an even number of players. 28 | /// Defaults to false. 29 | /// 30 | public bool AddEmptyPlayerSlotForEvenCount { get; set; } = false; 31 | 32 | private readonly INetworkConfig _networkConfig; 33 | private static SiraLog? _logger; 34 | 35 | internal PlayerCountPatcher( 36 | INetworkConfig networkConfig, 37 | SiraLog logger) 38 | { 39 | _networkConfig = networkConfig; 40 | _logger = logger; 41 | } 42 | 43 | [AffinityPrefix] 44 | [AffinityPatch(typeof(CreateServerFormController), nameof(CreateServerFormController.Setup))] 45 | private void CreateServerFormSetup(ref int selectedNumberOfPlayers, FormattedFloatListSettingsController ____maxPlayersList) 46 | { 47 | _logger.Debug($"Creating server form with player clamp between '{MinPlayers}' and '{MaxPlayers}'"); 48 | selectedNumberOfPlayers = Mathf.Clamp(selectedNumberOfPlayers, MinPlayers, MaxPlayers); 49 | ____maxPlayersList.values = Enumerable.Range(MinPlayers, MaxPlayers - MinPlayers + 1).Select(x => (float)x).ToArray(); 50 | } 51 | 52 | [HarmonyTranspiler] 53 | [HarmonyPatch(typeof(CreateServerFormController), nameof(CreateServerFormController.formData), MethodType.Getter)] 54 | private static IEnumerable CreateServerFormData(IEnumerable instructions) 55 | { 56 | var codes = instructions.ToList(); 57 | for (int i = 0; i < codes.Count; i++) 58 | { 59 | if (codes[i].opcode == OpCodes.Ldc_R4 && codes[i + 1].opcode == OpCodes.Ldc_R4) 60 | { 61 | codes[i + 2] = new CodeInstruction(OpCodes.Call, SymbolExtensions.GetMethodInfo(() => ClampFloatAttacher(0f, 0f, 0f))); 62 | } 63 | } 64 | return codes.AsEnumerable(); 65 | } 66 | 67 | [HarmonyPrefix] 68 | [HarmonyPatch(typeof(MultiplayerLobbyController), nameof(MultiplayerLobbyController.ActivateMultiplayerLobby))] 69 | private static void LoadLobby(ref float ____innerCircleRadius, ref float ____minOuterCircleRadius) 70 | { 71 | // Fix circle for bigger player counts 72 | ____innerCircleRadius = 1f; 73 | ____minOuterCircleRadius = 4.4f; 74 | } 75 | 76 | [HarmonyPostfix] 77 | [HarmonyPatch(typeof(MultiplayerLobbyController), nameof(MultiplayerLobbyController.ActivateMultiplayerLobby))] 78 | private static void LoadLobby_Post(float ____innerCircleRadius, float ____minOuterCircleRadius, ref MultiplayerLobbyCenterStageManager ____multiplayerLobbyCenterStageManager) 79 | { 80 | var maxPlayers = ____multiplayerLobbyCenterStageManager._lobbyStateDataModel.configuration.maxPlayerCount; 81 | float angleBetweenPlayersWithEvenAdjustment = MultiplayerPlayerPlacement.GetAngleBetweenPlayersWithEvenAdjustment(maxPlayers, MultiplayerPlayerLayout.Circle); 82 | float outerCircleRadius = Mathf.Max(MultiplayerPlayerPlacement.GetOuterCircleRadius(angleBetweenPlayersWithEvenAdjustment, ____innerCircleRadius), ____innerCircleRadius); 83 | 84 | float centerScreenScale = Mathf.Max(outerCircleRadius / ____minOuterCircleRadius, ____innerCircleRadius); 85 | _logger.Info($"innerCircleRadius is {____innerCircleRadius}, minOuterCircleRadius is {____minOuterCircleRadius}, maxPlayers is {maxPlayers}, angleBetweenPlayersWithEvenAdjustment is {angleBetweenPlayersWithEvenAdjustment}, outerCircleRadius is {outerCircleRadius}, centerScreenScale is {centerScreenScale}"); 86 | 87 | ____multiplayerLobbyCenterStageManager.transform.localScale = new Vector3(centerScreenScale, centerScreenScale, centerScreenScale); 88 | } 89 | 90 | 91 | [AffinityTranspiler] 92 | [AffinityPatch(typeof(MultiplayerPlayerPlacement), nameof(MultiplayerPlayerPlacement.GetAngleBetweenPlayersWithEvenAdjustment))] 93 | private IEnumerable PlayerPlacementAngle(IEnumerable instructions) 94 | { 95 | var codes = instructions.ToList(); 96 | int divStartIndex = codes.FindIndex(code => code.opcode == OpCodes.Ldc_R4 && code.OperandIs(360)); 97 | if (!AddEmptyPlayerSlotForEvenCount && divStartIndex != -1) 98 | codes.RemoveRange(0, divStartIndex); 99 | return codes.AsEnumerable(); 100 | } 101 | 102 | [AffinityTranspiler] 103 | [AffinityPatch(typeof(MultiplayerLayoutProvider), nameof(MultiplayerLayoutProvider.CalculateLayout))] 104 | private IEnumerable PlayerGameplayLayout(IEnumerable instructions) 105 | { 106 | var codes = instructions.ToList(); 107 | for (int i = 0; i < codes.Count; i++) 108 | { 109 | if (!AddEmptyPlayerSlotForEvenCount && codes[i].opcode == OpCodes.Ldc_I4_1 && codes[i + 1].opcode == OpCodes.Add) 110 | codes.RemoveRange(i, 2); 111 | } 112 | return codes.AsEnumerable(); 113 | } 114 | 115 | private static float ClampFloatAttacher(float value, float min, float max) 116 | => value; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /MultiplayerCore/Objects/MpLevelLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using HarmonyLib; 6 | using JetBrains.Annotations; 7 | using SiraUtil.Logging; 8 | 9 | namespace MultiplayerCore.Objects 10 | { 11 | public class MpLevelLoader : MultiplayerLevelLoader, IProgress 12 | { 13 | public event Action progressUpdated = null!; 14 | 15 | public ILevelGameplaySetupData? CurrentLoadingData => _gameplaySetupData; 16 | 17 | internal readonly IMultiplayerSessionManager _sessionManager; 18 | internal readonly MpLevelDownloader _levelDownloader; 19 | internal readonly MpEntitlementChecker _entitlementChecker; 20 | internal readonly IMenuRpcManager _rpcManager; 21 | internal readonly SiraLog _logger; 22 | 23 | internal MpLevelLoader( 24 | IMultiplayerSessionManager sessionManager, 25 | MpLevelDownloader levelDownloader, 26 | NetworkPlayerEntitlementChecker entitlementChecker, 27 | IMenuRpcManager rpcManager, 28 | SiraLog logger) 29 | { 30 | _sessionManager = sessionManager; 31 | _levelDownloader = levelDownloader; 32 | _entitlementChecker = (entitlementChecker as MpEntitlementChecker)!; 33 | _rpcManager = rpcManager; 34 | _logger = logger; 35 | } 36 | 37 | [UsedImplicitly] 38 | public void LoadLevel_override(string levelId) 39 | { 40 | var levelHash = Utilities.HashForLevelID(levelId); 41 | 42 | if (levelHash == null) 43 | { 44 | _logger.Debug($"Ignoring level (not a custom level hash): {levelId}"); 45 | return; 46 | } 47 | 48 | var downloadNeeded = !SongCore.Collections.songWithHashPresent(levelHash); 49 | 50 | _logger.Debug($"Loading level: {levelId} (downloadNeeded={downloadNeeded})"); 51 | 52 | if (downloadNeeded) 53 | _getBeatmapLevelResultTask = DownloadBeatmapLevelAsync(levelId, _getBeatmapCancellationTokenSource.Token); 54 | } 55 | 56 | internal void UnloadLevelIfRequirementsNotMet() 57 | { 58 | // Extra: load finished, check if there are extra requirements in place 59 | // If we fail requirements, unload the level 60 | 61 | var beatmapKey = _gameplaySetupData.beatmapKey; 62 | var levelId = beatmapKey.levelId; 63 | 64 | var levelHash = Utilities.HashForLevelID(levelId); 65 | if (levelHash == null) 66 | return; 67 | 68 | var SongData = SongCore.Collections.GetCustomLevelSongData(levelId); 69 | if (SongData == null) 70 | return; 71 | 72 | var difficulty = _gameplaySetupData.beatmapKey.difficulty; 73 | var characteristicName = _gameplaySetupData.beatmapKey.beatmapCharacteristic.serializedName; 74 | 75 | var difficultyData = SongData._difficulties.FirstOrDefault(x => 76 | x._difficulty == difficulty && x._beatmapCharacteristicName == characteristicName); 77 | if (difficultyData == null) 78 | return; 79 | 80 | var requirementsMet = true; 81 | foreach (var requirement in difficultyData.additionalDifficultyData._requirements) 82 | { 83 | if (SongCore.Collections.capabilities.Contains(requirement)) 84 | continue; 85 | _logger.Warn($"Level requirements not met: {requirement}"); 86 | requirementsMet = false; 87 | } 88 | 89 | if (requirementsMet) 90 | return; 91 | 92 | _logger.Warn($"Level will be unloaded due to unmet requirements"); 93 | _beatmapLevelData = null!; 94 | } 95 | 96 | public void Report(double value) 97 | => progressUpdated?.Invoke(value); 98 | 99 | /// 100 | /// Downloads a custom level, and then loads and returns its data. 101 | /// 102 | private async Task DownloadBeatmapLevelAsync(string levelId, CancellationToken cancellationToken) 103 | { 104 | // Download from BeatSaver 105 | var success = await _levelDownloader.TryDownloadLevel(levelId, cancellationToken, this); 106 | if (!success) 107 | { 108 | // If the download fails we go into spectator 109 | _rpcManager.SetIsEntitledToLevel(levelId, EntitlementsStatus.NotOwned); 110 | _beatmapLevelData = null; 111 | return LoadBeatmapLevelDataResult.Error; 112 | } 113 | 114 | // Reload custom level set 115 | _logger.Debug("Reloading custom level collection..."); 116 | while (!SongCore.Loader.AreSongsLoaded) 117 | { 118 | await Task.Delay(25); 119 | } 120 | 121 | // Load level data 122 | var method = AccessTools.Method(_beatmapLevelsModel.GetType(), nameof(_beatmapLevelsModel.LoadBeatmapLevelDataAsync)); 123 | LoadBeatmapLevelDataResult loadResult = LoadBeatmapLevelDataResult.Error; 124 | if (method != null) 125 | { 126 | if (method.GetParameters().Length > 2) 127 | loadResult = await (Task)method.Invoke(_beatmapLevelsModel, 128 | new object[] { levelId, 0, cancellationToken }); 129 | else if (method.GetParameters().Length == 2) 130 | loadResult = await (Task)method.Invoke(_beatmapLevelsModel, 131 | new object[] { levelId, cancellationToken }); 132 | else throw new NotSupportedException("Game version not supported"); 133 | } 134 | //var loadResult = await _beatmapLevelsModel.LoadBeatmapLevelDataAsync(levelId, BeatmapLevelDataVersion.Original, cancellationToken); 135 | if (loadResult.isError) 136 | _logger.Error($"Custom level data could not be loaded after download: {levelId}"); 137 | return loadResult; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /MultiplayerCore/Patchers/NetworkConfigPatcher.cs: -------------------------------------------------------------------------------- 1 | using IgnoranceCore; 2 | using SiraUtil.Affinity; 3 | using SiraUtil.Logging; 4 | 5 | // ReSharper disable RedundantAssignment 6 | // ReSharper disable MemberCanBePrivate.Global 7 | // ReSharper disable InconsistentNaming 8 | 9 | namespace MultiplayerCore.Patchers 10 | { 11 | public class NetworkConfigPatcher : IAffinity 12 | { 13 | public const int OfficialMaxPartySize = 5; 14 | 15 | public string? GraphUrl { get; set; } 16 | public string? MasterServerStatusUrl { get; set; } 17 | public string? QuickPlaySetupUrl { get; set; } 18 | public int? MaxPartySize { get; set; } 19 | public int? DiscoveryPort { get; set; } 20 | public int? PartyPort { get; set; } 21 | public int? MultiplayerPort { get; set; } 22 | /// 23 | /// If set: disable SSL and certificate validation for all Ignorance/ENet client connections. 24 | /// 25 | public bool DisableSsl { get; set; } = false; 26 | 27 | public bool IsOverridingApi => GraphUrl != null; 28 | 29 | private readonly SiraLog _logger; 30 | 31 | internal NetworkConfigPatcher( 32 | SiraLog logger) 33 | { 34 | _logger = logger; 35 | } 36 | 37 | /// 38 | /// Override official servers with a custom API server. 39 | /// 40 | public void UseCustomApiServer(string graphUrl, string? statusUrl, int? maxPartySize = null, 41 | string? quickPlaySetupUrl = null, bool disableSsl = true) 42 | { 43 | quickPlaySetupUrl = quickPlaySetupUrl ?? (statusUrl != null ? statusUrl + "/mp_override.json" : null); 44 | 45 | _logger.Debug($"Overriding multiplayer API server (graphUrl={graphUrl}, statusUrl={statusUrl}, " + 46 | $"maxPartySize={maxPartySize}, quickPlaySetupUrl={quickPlaySetupUrl})"); 47 | 48 | GraphUrl = graphUrl; 49 | MasterServerStatusUrl = statusUrl; 50 | MaxPartySize = maxPartySize; 51 | QuickPlaySetupUrl = quickPlaySetupUrl; 52 | DisableSsl = disableSsl; 53 | } 54 | 55 | /// 56 | /// Use the official API server and disable any network overrides. 57 | /// 58 | public void UseOfficialServer() 59 | { 60 | _logger.Debug($"Removed multiplayer API server override"); 61 | 62 | GraphUrl = null; 63 | MasterServerStatusUrl = null; 64 | MaxPartySize = null; 65 | QuickPlaySetupUrl = null; 66 | DisableSsl = false; 67 | } 68 | 69 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.graphUrl), AffinityMethodType.Getter)] 70 | private void GetGraphUrl(ref string __result) 71 | { 72 | if (!IsOverridingApi) 73 | return; 74 | 75 | __result = GraphUrl!; 76 | } 77 | 78 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.multiplayerStatusUrl), 79 | AffinityMethodType.Getter)] 80 | private void GetMasterServerStatusUrl(ref string __result) 81 | { 82 | if (MasterServerStatusUrl == null) 83 | return; 84 | 85 | __result = MasterServerStatusUrl!; 86 | } 87 | 88 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.maxPartySize), AffinityMethodType.Getter)] 89 | private void GetMaxPartySize(ref int __result) 90 | { 91 | if (MaxPartySize == null) 92 | { 93 | __result = OfficialMaxPartySize; 94 | return; 95 | } 96 | 97 | __result = MaxPartySize.Value; 98 | } 99 | 100 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.quickPlaySetupUrl), AffinityMethodType.Getter)] 101 | private void GetQuickPlaySetupUrl(ref string __result) 102 | { 103 | if (QuickPlaySetupUrl == null) 104 | return; 105 | 106 | __result = QuickPlaySetupUrl; 107 | } 108 | 109 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.discoveryPort), AffinityMethodType.Getter)] 110 | private void GetDiscoveryPort(ref int __result) 111 | { 112 | if (DiscoveryPort == null) 113 | return; 114 | 115 | __result = DiscoveryPort.Value; 116 | } 117 | 118 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.partyPort), AffinityMethodType.Getter)] 119 | private void GetPartyPort(ref int __result) 120 | { 121 | if (PartyPort == null) 122 | return; 123 | 124 | __result = PartyPort.Value; 125 | } 126 | 127 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.multiplayerPort), AffinityMethodType.Getter)] 128 | private void GetMultiplayerPort(ref int __result) 129 | { 130 | if (MultiplayerPort == null) 131 | return; 132 | 133 | __result = MultiplayerPort.Value; 134 | } 135 | 136 | [AffinityPatch(typeof(NetworkConfigSO), nameof(NetworkConfigSO.forceGameLift), AffinityMethodType.Getter)] 137 | private void GetForceGameLift(ref bool __result) 138 | { 139 | // If we're overriding the API, the game should always use GameLift connection manager flow 140 | __result = !IsOverridingApi; 141 | } 142 | 143 | [AffinityPrefix] 144 | [AffinityPatch(typeof(UnifiedNetworkPlayerModel), 145 | nameof(UnifiedNetworkPlayerModel.SetActiveNetworkPlayerModelType))] 146 | private void PrefixSetActiveNetworkPlayerModelType( 147 | ref UnifiedNetworkPlayerModel.ActiveNetworkPlayerModelType activeNetworkPlayerModelType) 148 | { 149 | if (!IsOverridingApi) 150 | return; 151 | 152 | // If we're overriding the API, the game should always use GameLift connection manager flow 153 | activeNetworkPlayerModelType = UnifiedNetworkPlayerModel.ActiveNetworkPlayerModelType.GameLift; 154 | } 155 | 156 | [AffinityPrefix] 157 | [AffinityPatch(typeof(ClientCertificateValidator), "ValidateCertificateChainInternal")] 158 | private bool ValidateCertificateChain() 159 | { 160 | return !IsOverridingApi; 161 | } 162 | 163 | [AffinityPrefix] 164 | [AffinityPatch(typeof(IgnoranceClient), "Start")] 165 | private void PrefixIgnoranceClientStart(IgnoranceClient __instance) 166 | { 167 | if (DisableSsl) 168 | __instance.UseSsl = false; 169 | if (IsOverridingApi) 170 | __instance.ValidateCertificate = false; 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /MultiplayerCore/UI/MpColorsUI.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using BeatSaberMarkupLanguage; 5 | using BeatSaberMarkupLanguage.Attributes; 6 | using BeatSaberMarkupLanguage.Components; 7 | using BeatSaberMarkupLanguage.Components.Settings; 8 | using HMUI; 9 | using MultiplayerCore.Beatmaps.Serializable; 10 | using MultiplayerCore.Helpers; 11 | using SongCore.Data; 12 | using UnityEngine; 13 | 14 | namespace MultiplayerCore.UI 15 | { 16 | internal class MpColorsUI : NotifiableBase 17 | { 18 | public const string ResourcePath = "MultiplayerCore.UI.ColorsUI.bsml"; 19 | 20 | public event Action dismissedEvent = null!; 21 | 22 | private ColorSchemeView colorSchemeView = null!; 23 | private readonly Color voidColor = new Color(0.5f, 0.5f, 0.5f, 0.25f); 24 | 25 | private readonly LobbySetupViewController _lobbySetupViewController; 26 | 27 | [UIComponent("noteColorsToggle")] 28 | private ToggleSetting noteColorToggle; 29 | 30 | [UIComponent("environmentColorsToggle")] 31 | private ToggleSetting environmentColorToggle; 32 | 33 | [UIComponent("obstacleColorsToggle")] 34 | private ToggleSetting obstacleColorsToggle; 35 | 36 | internal MpColorsUI(LobbySetupViewController lobbySetupViewController) => _lobbySetupViewController = lobbySetupViewController; 37 | 38 | [UIComponent("modal")] 39 | private readonly ModalView _modal = null!; 40 | 41 | private Vector3 _modalPosition; 42 | 43 | [UIComponent("selected-color")] 44 | private readonly RectTransform selectedColorTransform = null!; 45 | 46 | [UIValue("noteColors")] 47 | public bool NoteColors 48 | { 49 | get => SongCoreConfig.CustomSongNoteColors; 50 | set => SongCoreConfig.CustomSongNoteColors = value; 51 | } 52 | 53 | [UIValue("obstacleColors")] 54 | public bool ObstacleColors 55 | { 56 | get => SongCoreConfig.CustomSongObstacleColors; 57 | set => SongCoreConfig.CustomSongObstacleColors = value; 58 | } 59 | 60 | [UIValue("environmentColors")] 61 | public bool EnvironmentColors 62 | { 63 | get => SongCoreConfig.CustomSongEnvironmentColors; 64 | set => SongCoreConfig.CustomSongEnvironmentColors = value; 65 | } 66 | 67 | internal void ShowColors() 68 | { 69 | Parse(); 70 | _modal.Show(true); 71 | 72 | // We do this to apply any changes to the toggles that might have been made from within SongCores UI 73 | noteColorToggle.Value = SongCoreConfig.CustomSongNoteColors; 74 | obstacleColorsToggle.Value = SongCoreConfig.CustomSongObstacleColors; 75 | environmentColorToggle.Value = SongCoreConfig.CustomSongEnvironmentColors; 76 | } 77 | 78 | private void Parse() 79 | { 80 | if (!_modal) 81 | BSMLParser.Instance.Parse(BeatSaberMarkupLanguage.Utilities.GetResourceContent(Assembly.GetExecutingAssembly(), ResourcePath), _lobbySetupViewController.GetComponentInChildren(true).gameObject, this); 82 | _modal.transform.localPosition = _modalPosition; 83 | } 84 | 85 | [UIAction("#post-parse")] 86 | private void PostParse() 87 | { 88 | ColorSchemeView colorSchemeViewPrefab = GameObject.Instantiate(Resources.FindObjectsOfTypeAll().First(), selectedColorTransform); 89 | colorSchemeView = IPA.Utilities.ReflectionUtil.CopyComponent(colorSchemeViewPrefab, colorSchemeViewPrefab.gameObject); 90 | GameObject.DestroyImmediate(colorSchemeViewPrefab); 91 | _modalPosition = _modal.transform.localPosition; 92 | _modal.blockerClickedEvent += Dismiss; 93 | } 94 | 95 | private void Dismiss() => _modal.Hide(false, dismissedEvent); 96 | 97 | internal void AcceptColors(DifficultyColors colors) 98 | { 99 | Parse(); 100 | 101 | Color saberLeft = colors.ColorLeft == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.ColorLeft); 102 | Color saberRight = colors.ColorRight == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.ColorRight); 103 | Color envLeft = colors.EnvColorLeft == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.EnvColorLeft); 104 | Color envRight = colors.EnvColorRight == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.EnvColorRight); 105 | Color envLeftBoost = colors.EnvColorLeftBoost == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.EnvColorLeftBoost); 106 | Color envRightBoost = colors.EnvColorRightBoost == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.EnvColorRightBoost); 107 | Color obstacle = colors.ObstacleColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(colors.ObstacleColor); 108 | 109 | colorSchemeView.SetColors( 110 | saberLeft, 111 | saberRight, 112 | envLeft, 113 | envRight, 114 | envLeftBoost, 115 | envRightBoost, 116 | obstacle 117 | ); 118 | } 119 | 120 | internal void AcceptColors(SongData.MapColor? leftColor, SongData.MapColor? rightColor, SongData.MapColor? envLeftColor, SongData.MapColor? envLeftBoostColor, SongData.MapColor? envRightColor, SongData.MapColor? envRightBoostColor, SongData.MapColor? obstacleColor) 121 | { 122 | Parse(); 123 | 124 | Color saberLeft = leftColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(leftColor); 125 | Color saberRight = rightColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(rightColor); 126 | Color envLeft = envLeftColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(envLeftColor); 127 | Color envRight = envRightColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(envRightColor); 128 | Color envLeftBoost = envLeftBoostColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(envLeftBoostColor); 129 | Color envRightBoost = envRightBoostColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(envRightBoostColor); 130 | Color obstacle = obstacleColor == null ? voidColor : SongCore.Utilities.Utils.ColorFromMapColor(obstacleColor); 131 | 132 | colorSchemeView.SetColors( 133 | saberLeft, 134 | saberRight, 135 | envLeft, 136 | envRight, 137 | envLeftBoost, 138 | envRightBoost, 139 | obstacle 140 | ); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | *_i.c 64 | *_p.c 65 | *_h.h 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.iobj 70 | *.pch 71 | *.pdb 72 | *.ipdb 73 | *.pgc 74 | *.pgd 75 | *.rsp 76 | *.sbr 77 | *.tlb 78 | *.tli 79 | *.tlh 80 | *.tmp 81 | *.tmp_proj 82 | *_wpftmp.csproj 83 | *.log 84 | *.vspscc 85 | *.vssscc 86 | .builds 87 | *.pidb 88 | *.svclog 89 | *.scc 90 | 91 | # Chutzpah Test files 92 | _Chutzpah* 93 | 94 | # Visual C++ cache files 95 | ipch/ 96 | *.aps 97 | *.ncb 98 | *.opendb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | *.VC.db 103 | *.VC.VC.opendb 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | *.sap 110 | 111 | # Visual Studio Trace Files 112 | *.e2e 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # AxoCover is a Code Coverage Tool 135 | .axoCover/* 136 | !.axoCover/settings.json 137 | 138 | # Visual Studio code coverage results 139 | *.coverage 140 | *.coveragexml 141 | 142 | # NCrunch 143 | _NCrunch_* 144 | .*crunch*.local.xml 145 | nCrunchTemp_* 146 | 147 | # MightyMoose 148 | *.mm.* 149 | AutoTest.Net/ 150 | 151 | # Web workbench (sass) 152 | .sass-cache/ 153 | 154 | # Installshield output folder 155 | [Ee]xpress/ 156 | 157 | # DocProject is a documentation generator add-in 158 | DocProject/buildhelp/ 159 | DocProject/Help/*.HxT 160 | DocProject/Help/*.HxC 161 | DocProject/Help/*.hhc 162 | DocProject/Help/*.hhk 163 | DocProject/Help/*.hhp 164 | DocProject/Help/Html2 165 | DocProject/Help/html 166 | 167 | # Click-Once directory 168 | publish/ 169 | 170 | # Publish Web Output 171 | *.[Pp]ublish.xml 172 | *.azurePubxml 173 | # Note: Comment the next line if you want to checkin your web deploy settings, 174 | # but database connection strings (with potential passwords) will be unencrypted 175 | *.pubxml 176 | *.publishproj 177 | 178 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 179 | # checkin your Azure Web App publish settings, but sensitive information contained 180 | # in these scripts will be unencrypted 181 | PublishScripts/ 182 | 183 | # NuGet Packages 184 | *.nupkg 185 | # The packages folder can be ignored because of Package Restore 186 | **/[Pp]ackages/* 187 | # except build/, which is used as an MSBuild target. 188 | !**/[Pp]ackages/build/ 189 | # Uncomment if necessary however generally it will be regenerated when needed 190 | #!**/[Pp]ackages/repositories.config 191 | # NuGet v3's project.json files produces more ignorable files 192 | *.nuget.props 193 | *.nuget.targets 194 | 195 | # Microsoft Azure Build Output 196 | csx/ 197 | *.build.csdef 198 | 199 | # Microsoft Azure Emulator 200 | ecf/ 201 | rcf/ 202 | 203 | # Windows Store app package directories and files 204 | AppPackages/ 205 | BundleArtifacts/ 206 | Package.StoreAssociation.xml 207 | _pkginfo.txt 208 | *.appx 209 | 210 | # Visual Studio cache files 211 | # files ending in .cache can be ignored 212 | *.[Cc]ache 213 | # but keep track of directories ending in .cache 214 | !?*.[Cc]ache/ 215 | 216 | # Others 217 | ClientBin/ 218 | ~$* 219 | *~ 220 | *.dbmdl 221 | *.dbproj.schemaview 222 | *.jfm 223 | *.pfx 224 | *.publishsettings 225 | orleans.codegen.cs 226 | 227 | # Including strong name files can present a security risk 228 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 229 | #*.snk 230 | 231 | # Since there are multiple workflows, uncomment next line to ignore bower_components 232 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 233 | #bower_components/ 234 | 235 | # RIA/Silverlight projects 236 | Generated_Code/ 237 | 238 | # Backup & report files from converting an old project file 239 | # to a newer Visual Studio version. Backup files are not needed, 240 | # because we have git ;-) 241 | _UpgradeReport_Files/ 242 | Backup*/ 243 | UpgradeLog*.XML 244 | UpgradeLog*.htm 245 | ServiceFabricBackup/ 246 | *.rptproj.bak 247 | 248 | # SQL Server files 249 | *.mdf 250 | *.ldf 251 | *.ndf 252 | 253 | # Business Intelligence projects 254 | *.rdl.data 255 | *.bim.layout 256 | *.bim_*.settings 257 | *.rptproj.rsuser 258 | *- Backup*.rdl 259 | 260 | # Microsoft Fakes 261 | FakesAssemblies/ 262 | 263 | # GhostDoc plugin setting file 264 | *.GhostDoc.xml 265 | 266 | # Node.js Tools for Visual Studio 267 | .ntvs_analysis.dat 268 | node_modules/ 269 | 270 | # Visual Studio 6 build log 271 | *.plg 272 | 273 | # Visual Studio 6 workspace options file 274 | *.opt 275 | 276 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 277 | *.vbw 278 | 279 | # Visual Studio LightSwitch build output 280 | **/*.HTMLClient/GeneratedArtifacts 281 | **/*.DesktopClient/GeneratedArtifacts 282 | **/*.DesktopClient/ModelManifest.xml 283 | **/*.Server/GeneratedArtifacts 284 | **/*.Server/ModelManifest.xml 285 | _Pvt_Extensions 286 | 287 | # Paket dependency manager 288 | .paket/paket.exe 289 | paket-files/ 290 | 291 | # FAKE - F# Make 292 | .fake/ 293 | 294 | # JetBrains Rider 295 | .idea/ 296 | *.sln.iml 297 | 298 | # CodeRush personal settings 299 | .cr/personal 300 | 301 | # Python Tools for Visual Studio (PTVS) 302 | __pycache__/ 303 | *.pyc 304 | 305 | # Cake - Uncomment if you are using it 306 | # tools/** 307 | # !tools/packages.config 308 | 309 | # Tabs Studio 310 | *.tss 311 | 312 | # Telerik's JustMock configuration file 313 | *.jmconfig 314 | 315 | # BizTalk build output 316 | *.btp.cs 317 | *.btm.cs 318 | *.odx.cs 319 | *.xsd.cs 320 | 321 | # OpenCover UI analysis results 322 | OpenCover/ 323 | 324 | # Azure Stream Analytics local run output 325 | ASALocalRun/ 326 | 327 | # MSBuild Binary and Structured Log 328 | *.binlog 329 | 330 | # NVidia Nsight GPU debugger configuration file 331 | *.nvuser 332 | 333 | # MFractors (Xamarin productivity tool) working folder 334 | .mfractor/ 335 | 336 | # Local History for Visual Studio 337 | .localhistory/ 338 | 339 | # BeatPulse healthcheck temp database 340 | healthchecksdb 341 | 342 | Refs/Beat Saber_Data/Managed/* 343 | Refs/Plugins/* 344 | Refs/Libs/Mono* 345 | !Refs/Beat Saber_Data/Managed/IPA.Loader.dll 346 | /bsinstalldir.txt 347 | 348 | MultiplayerCore/Properties/launchSettings.json 349 | -------------------------------------------------------------------------------- /MultiplayerCore/Objects/MpLevelDownloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using BeatSaverSharp; 8 | using BeatSaverSharp.Models; 9 | using IPA.Utilities; 10 | using MultiplayerCore.Helpers; 11 | using SiraUtil.Logging; 12 | using SiraUtil.Zenject; 13 | using UnityEngine; 14 | 15 | namespace MultiplayerCore.Objects 16 | { 17 | internal class MpLevelDownloader 18 | { 19 | public readonly string CustomLevelsFolder = Path.Combine(Application.dataPath, UnityGame.GameVersion >= new AlmostVersion("1.37.3") ? Plugin.CustomLevelsPath : "CustomLevels"); 20 | 21 | private ConcurrentDictionary> _downloads = new(); 22 | private readonly ZipExtractor _zipExtractor = new(); 23 | private readonly BeatSaver _beatsaver; 24 | private readonly SiraLog _logger; 25 | 26 | internal MpLevelDownloader( 27 | UBinder beatsaver, 28 | SiraLog logger) 29 | { 30 | _beatsaver = beatsaver.Value; 31 | _logger = logger; 32 | } 33 | 34 | /// 35 | /// Gets the download task for a level if it is downloading. 36 | /// 37 | /// Level to check for 38 | /// Download task 39 | /// Whether level is downloading or not 40 | public bool TryGetDownload(string levelId, out Task task) 41 | => _downloads.TryGetValue(levelId, out task); 42 | 43 | /// 44 | /// Tries to download a level. 45 | /// 46 | /// Level to download 47 | /// Cancellation token 48 | /// Progress object 49 | /// Task that is false when download fails and true when successful 50 | public Task TryDownloadLevel(string levelId, CancellationToken cancellationToken, IProgress? progress = null) 51 | { 52 | Task task; 53 | if (_downloads.TryGetValue(levelId, out task)) 54 | { 55 | if (!task.IsCompleted) 56 | _logger.Debug($"Download already in progress: {levelId}"); 57 | if (task.IsCompleted && task.Result) 58 | _logger.Debug($"Download already finished: {levelId}"); 59 | } 60 | if (task == null || (task.IsCompleted && !task.Result)) 61 | { 62 | _logger.Debug($"Starting download: {levelId}"); 63 | task = TryDownloadLevelInternal(levelId, cancellationToken, progress); 64 | _downloads[levelId] = task; 65 | } 66 | return task; 67 | } 68 | 69 | private async Task TryDownloadLevelInternal(string levelId, CancellationToken cancellationToken, IProgress? progress = null) 70 | { 71 | string? levelHash = Utilities.HashForLevelID(levelId); 72 | if (string.IsNullOrEmpty(levelHash)) 73 | { 74 | _logger.Error($"Could not parse hash from id {levelId}"); 75 | return false; 76 | } 77 | 78 | try 79 | { 80 | await DownloadLevel(levelHash!, cancellationToken, progress); 81 | _logger.Debug($"Download finished: {levelId}"); 82 | _downloads.TryRemove(levelId, out _); 83 | return true; 84 | } 85 | catch (OperationCanceledException) 86 | { 87 | _logger.Debug($"Download cancelled: {levelId}"); 88 | } 89 | catch (Exception ex) 90 | { 91 | _logger.Error($"Download failed: {levelId} {ex.Message}"); 92 | _logger.Debug(ex); 93 | } 94 | return false; 95 | } 96 | 97 | private async Task DownloadLevel(string levelHash, CancellationToken cancellationToken, IProgress? progress = null) 98 | { 99 | Beatmap? beatmap = await _beatsaver.BeatmapByHash(levelHash, cancellationToken); 100 | if (beatmap == null) 101 | throw new Exception("Not found on BeatSaver."); 102 | 103 | BeatmapVersion? beatmapVersion = beatmap.Versions.FirstOrDefault(x => string.Equals(x.Hash, levelHash, StringComparison.OrdinalIgnoreCase)); 104 | if (beatmapVersion == null!) 105 | throw new Exception("Not found in versions provided by BeatSaver."); 106 | byte[]? beatmapBytes = await beatmapVersion.DownloadZIP(cancellationToken, progress); 107 | 108 | string folderPath = GetSongDirectoryName(beatmap.LatestVersion.Key, beatmap.Metadata.SongName, beatmap.Metadata.LevelAuthorName); 109 | folderPath = Path.Combine(CustomLevelsFolder, folderPath); 110 | using (MemoryStream memoryStream = new MemoryStream(beatmapBytes)) 111 | { 112 | var result = await _zipExtractor.ExtractZip(memoryStream, folderPath); 113 | if (folderPath != result.OutputDirectory) 114 | folderPath = result.OutputDirectory ?? throw new Exception("Zip extract failed, no output directory."); 115 | if (result.Exception != null) 116 | throw result.Exception; 117 | } 118 | 119 | using (var awaiter = new EventAwaiter>(cancellationToken)) 120 | { 121 | try 122 | { 123 | SongCore.Loader.SongsLoadedEvent += awaiter.OnEvent; 124 | SongCore.Loader.Instance.RefreshSongs(false); 125 | await awaiter.Task; 126 | } 127 | catch (Exception) 128 | { 129 | throw; 130 | } 131 | finally 132 | { 133 | SongCore.Loader.SongsLoadedEvent -= awaiter.OnEvent; 134 | } 135 | } 136 | } 137 | 138 | private string GetSongDirectoryName(string? songKey, string songName, string levelAuthorName) 139 | { 140 | // BeatSaverDownloader's method of naming the directory. 141 | string basePath; 142 | string nameAuthor; 143 | if (string.IsNullOrEmpty(levelAuthorName)) 144 | nameAuthor = songName; 145 | else 146 | nameAuthor = $"{songName} - {levelAuthorName}"; 147 | songKey = songKey?.Trim(); 148 | if (songKey != null && songKey.Length > 0) 149 | basePath = songKey + " (" + nameAuthor + ")"; 150 | else 151 | basePath = nameAuthor; 152 | basePath = string.Concat(basePath.Trim().Split(_invalidPathChars)); 153 | return basePath; 154 | } 155 | 156 | private readonly char[] _invalidPathChars = new char[] 157 | { 158 | '<', '>', ':', '/', '\\', '|', '?', '*', '"', 159 | '\u0000', '\u0001', '\u0002', '\u0003', '\u0004', '\u0005', '\u0006', '\u0007', 160 | '\u0008', '\u0009', '\u000a', '\u000b', '\u000c', '\u000d', '\u000e', '\u000d', 161 | '\u000f', '\u0010', '\u0011', '\u0012', '\u0013', '\u0014', '\u0015', '\u0016', 162 | '\u0017', '\u0018', '\u0019', '\u001a', '\u001b', '\u001c', '\u001d', '\u001f', 163 | }.Concat(Path.GetInvalidPathChars()).Distinct().ToArray(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /MultiplayerCore/Networking/MpPacketSerializer.cs: -------------------------------------------------------------------------------- 1 | using LiteNetLib.Utils; 2 | using MultiplayerCore.Networking.Attributes; 3 | using SiraUtil.Logging; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Reflection; 7 | using Zenject; 8 | 9 | namespace MultiplayerCore.Networking 10 | { 11 | public class MpPacketSerializer : INetworkPacketSubSerializer, IInitializable, IDisposable 12 | { 13 | private const int ID = 100; 14 | 15 | private Dictionary> packetHandlers = new(); 16 | private List registeredTypes = new(); 17 | 18 | private readonly MultiplayerSessionManager _sessionManager; 19 | private readonly SiraLog _logger; 20 | 21 | internal MpPacketSerializer( 22 | IMultiplayerSessionManager sessionManager, 23 | SiraLog logger) 24 | { 25 | _sessionManager = (sessionManager as MultiplayerSessionManager)!; 26 | _logger = logger; 27 | } 28 | 29 | public void Initialize() 30 | => _sessionManager.RegisterSerializer((MultiplayerSessionManager.MessageType)ID, this); 31 | 32 | public void Dispose() 33 | => _sessionManager.UnregisterSerializer((MultiplayerSessionManager.MessageType)ID, this); 34 | 35 | /// 36 | /// Method the base game uses to serialize an . 37 | /// 38 | /// The buffer to write to 39 | /// The packet to serialize 40 | public void Serialize(NetDataWriter writer, INetSerializable packet) 41 | { 42 | var packetType = packet.GetType(); 43 | var packetIdAttribute = packetType.GetCustomAttribute(); 44 | if (packetIdAttribute is not null) 45 | writer.Put(packetIdAttribute.ID); 46 | else 47 | writer.Put(packetType.Name); 48 | packet.Serialize(writer); 49 | } 50 | 51 | /// 52 | /// Method the base game uses to deserialize (handle) a packet. 53 | /// 54 | /// The buffer to read from 55 | /// Length of the packet 56 | /// The sender of the packet 57 | public void Deserialize(NetDataReader reader, int length, IConnectedPlayer data) 58 | { 59 | int prevPosition = reader.Position; 60 | string packetId = reader.GetString(); 61 | length -= reader.Position - prevPosition; 62 | prevPosition = reader.Position; 63 | 64 | Action action; 65 | if (packetHandlers.TryGetValue(packetId, out action) && action != null) 66 | { 67 | try 68 | { 69 | action(reader, length, data); 70 | } 71 | catch (Exception ex) 72 | { 73 | _logger.Warn($"An exception was thrown processing custom packet '{packetId}' from player '{data?.userName ?? ""}|{data?.userId ?? " < NULL > "}': {ex.Message}"); 74 | _logger.Debug(ex); 75 | } 76 | } 77 | 78 | // skip any unprocessed bytes (or rewind the reader if too many bytes were read) 79 | int processedBytes = reader.Position - prevPosition; 80 | reader.SkipBytes(length - processedBytes); 81 | } 82 | 83 | /// 84 | /// Method the base game uses to see if this serializer can handle a type. 85 | /// 86 | /// The type to be handled 87 | /// Whether this serializer can handle the type 88 | public bool HandlesType(Type type) 89 | { 90 | return registeredTypes.Contains(type); 91 | } 92 | 93 | /// 94 | /// Registers a packet without callback 95 | /// 96 | /// Type of packet to register. Inherits 97 | public void RegisterType() 98 | { 99 | var packetType = typeof(TPacket); 100 | var packetIdAttribute = packetType.GetCustomAttribute(); 101 | var packetId = packetIdAttribute is not null ? packetIdAttribute.ID : packetType.Name; 102 | registeredTypes.Add(packetType); 103 | } 104 | 105 | /// 106 | /// Registers a callback without sender for a packet. 107 | /// 108 | /// Type of packet to register. Inherits 109 | /// Action that handles received packet. 110 | /// 111 | public void RegisterCallback(Action callback) where TPacket : INetSerializable, new() 112 | => RegisterCallback((TPacket packet, IConnectedPlayer player) => callback?.Invoke(packet)); 113 | 114 | /// 115 | /// Registers a callback including sender for a packet. 116 | /// 117 | /// Type of packet to register. Inherits 118 | /// Action that handles received packet and sender 119 | /// 120 | public void RegisterCallback(Action callback) where TPacket : INetSerializable, new() 121 | { 122 | var packetType = typeof(TPacket); 123 | registeredTypes.Add(packetType); 124 | 125 | var packetIdAttribute = packetType.GetCustomAttribute(); 126 | var packetId = packetIdAttribute is not null ? packetIdAttribute.ID : packetType.Name; 127 | 128 | Func deserialize = delegate (NetDataReader reader, int size) 129 | { 130 | TPacket packet = new TPacket(); 131 | if (packet == null) 132 | { 133 | _logger.Error($"Constructor for '{packetType}' returned null!"); 134 | reader.SkipBytes(size); 135 | } 136 | else 137 | { 138 | packet.Deserialize(reader); 139 | } 140 | 141 | return packet!; 142 | }; 143 | 144 | packetHandlers[packetId] = delegate (NetDataReader reader, int size, IConnectedPlayer player) 145 | { 146 | callback(deserialize(reader, size), player); 147 | }; 148 | 149 | _logger.Debug($"Registered packet '{packetType}' with id '{packetId}'."); 150 | } 151 | 152 | /// 153 | /// Unregisters a callback for a packet. 154 | /// 155 | /// Type of packet to unregister. Inherits 156 | public void UnregisterCallback() where TPacket : INetSerializable, new() 157 | { 158 | var packetType = typeof(TPacket); 159 | var packetIdAttribute = packetType.GetCustomAttribute(); 160 | var packetId = packetIdAttribute is not null ? packetIdAttribute.ID : packetType.Name; 161 | packetHandlers.Remove(packetId); 162 | registeredTypes.Remove(packetType); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /MultiplayerCore/Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 2.0 7 | 8 | false 9 | 10 | $(OutputPath)$(AssemblyName) 11 | 12 | $(OutputPath)Final 13 | True 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | $(PluginVersion) 28 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | $(PluginVersion) 40 | $(PluginVersion) 41 | 42 | $(AssemblyName) 43 | $(ArtifactName)-$(PluginVersion) 44 | $(ArtifactName)-bs$(GameVersion) 45 | $(ArtifactName)-$(CommitHash) 46 | 47 | 48 | 49 | 50 | 51 | 52 | $(AssemblyName) 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | $(AssemblyName) 68 | $(OutDir)zip\ 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | $(BeatSaberDir)\Plugins 82 | True 83 | Unable to copy assembly to game folder, did you set 'BeatSaberDir' correctly in your 'csproj.user' file? Plugins folder doesn't exist: '$(PluginDir)'. 84 | 85 | Unable to copy to Plugins folder, '$(BeatSaberDir)' does not appear to be a Beat Saber game install. 86 | 87 | Unable to copy to Plugins folder, 'BeatSaberDir' has not been set in your 'csproj.user' file. 88 | False 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | $(BeatSaberDir)\IPA\Pending\Plugins 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /MultiplayerCore/Objects/MpPlayersDataModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using JetBrains.Annotations; 7 | using MultiplayerCore.Beatmaps; 8 | using MultiplayerCore.Beatmaps.Abstractions; 9 | using MultiplayerCore.Beatmaps.Packets; 10 | using MultiplayerCore.Beatmaps.Providers; 11 | using MultiplayerCore.Networking; 12 | using SiraUtil.Logging; 13 | 14 | namespace MultiplayerCore.Objects 15 | { 16 | [UsedImplicitly] 17 | public class MpPlayersDataModel : LobbyPlayersDataModel, ILobbyPlayersDataModel, IDisposable 18 | { 19 | private readonly MpPacketSerializer _packetSerializer; 20 | internal readonly MpBeatmapLevelProvider _beatmapLevelProvider; 21 | private readonly SiraLog _logger; 22 | private readonly Dictionary _lastPlayerBeatmapPackets = new(); 23 | public IReadOnlyDictionary PlayerPackets => _lastPlayerBeatmapPackets; 24 | 25 | internal MpPlayersDataModel( 26 | MpPacketSerializer packetSerializer, 27 | MpBeatmapLevelProvider beatmapLevelProvider, 28 | SiraLog logger) 29 | { 30 | _packetSerializer = packetSerializer; 31 | _beatmapLevelProvider = beatmapLevelProvider; 32 | _logger = logger; 33 | } 34 | 35 | public new void Activate() 36 | { 37 | _packetSerializer.RegisterCallback(HandleMpCoreBeatmapPacket); 38 | base.Activate(); 39 | _menuRpcManager.getRecommendedBeatmapEvent -= base.HandleMenuRpcManagerGetRecommendedBeatmap; 40 | _menuRpcManager.getRecommendedBeatmapEvent += this.HandleMenuRpcManagerGetRecommendedBeatmap; 41 | _menuRpcManager.recommendBeatmapEvent -= base.HandleMenuRpcManagerRecommendBeatmap; 42 | _menuRpcManager.recommendBeatmapEvent += this.HandleMenuRpcManagerRecommendBeatmap; 43 | _multiplayerSessionManager.playerConnectedEvent += HandlePlayerConnected; 44 | } 45 | 46 | public new void Deactivate() 47 | { 48 | _packetSerializer.UnregisterCallback(); 49 | _menuRpcManager.getRecommendedBeatmapEvent -= this.HandleMenuRpcManagerGetRecommendedBeatmap; 50 | _menuRpcManager.getRecommendedBeatmapEvent += base.HandleMenuRpcManagerGetRecommendedBeatmap; 51 | _menuRpcManager.recommendBeatmapEvent -= this.HandleMenuRpcManagerRecommendBeatmap; 52 | _menuRpcManager.recommendBeatmapEvent += base.HandleMenuRpcManagerRecommendBeatmap; 53 | _multiplayerSessionManager.playerConnectedEvent -= HandlePlayerConnected; 54 | base.Deactivate(); 55 | } 56 | 57 | public new void Dispose() 58 | => Deactivate(); 59 | 60 | internal void HandlePlayerConnected(IConnectedPlayer connectedPlayer) 61 | { 62 | // Send our MpBeatmapPacket again so newly joined players have it 63 | var selectedBeatmapKey = _playersData[localUserId].beatmapKey; 64 | if (selectedBeatmapKey.IsValid()) SendMpBeatmapPacket(selectedBeatmapKey, connectedPlayer); 65 | } 66 | internal void SetLocalPlayerBeatmapLevel_override(in BeatmapKey beatmapKey) 67 | { 68 | // Game: The local player has selected / recommended a beatmap 69 | 70 | // send extended beatmap info to other players 71 | SendMpBeatmapPacket(beatmapKey); 72 | 73 | //base.SetLocalPlayerBeatmapLevel(userId, in beatmapKey); 74 | } 75 | 76 | private void HandleMpCoreBeatmapPacket(MpBeatmapPacket packet, IConnectedPlayer player) 77 | { 78 | // Packet: Another player has recommended a beatmap (MpCore), we have received details for the level preview 79 | 80 | _logger.Debug($"'{player.userId}' selected song '{packet.levelHash}'."); 81 | 82 | var beatmap = _beatmapLevelProvider.GetBeatmapFromPacket(packet); 83 | var characteristic = _beatmapCharacteristicCollection.GetBeatmapCharacteristicBySerializedName(packet.characteristicName); 84 | 85 | PutPlayerPacket(player.userId, packet); 86 | base.SetPlayerBeatmapLevel(player.userId, new BeatmapKey(beatmap.LevelID, characteristic, packet.difficulty)); 87 | } 88 | 89 | private new void HandleMenuRpcManagerGetRecommendedBeatmap(string userId) 90 | { 91 | // RPC: The server / another player has asked us to send our recommended beatmap 92 | 93 | var selectedBeatmapKey = _playersData[localUserId].beatmapKey; 94 | SendMpBeatmapPacket(selectedBeatmapKey); 95 | 96 | base.HandleMenuRpcManagerGetRecommendedBeatmap(userId); 97 | } 98 | 99 | private new void HandleMenuRpcManagerRecommendBeatmap(string userId, BeatmapKeyNetSerializable beatmapKeySerializable) 100 | { 101 | // RPC: Another player has recommended a beatmap (base game) 102 | 103 | var levelHash = Utilities.HashForLevelID(beatmapKeySerializable.levelID); 104 | if (!string.IsNullOrEmpty(levelHash) && _beatmapLevelProvider.TryGetBeatmapFromPacketHash(levelHash!) != null) // If we have no packet run basegame behaviour 105 | return; 106 | 107 | base.HandleMenuRpcManagerRecommendBeatmap(userId, beatmapKeySerializable); 108 | } 109 | 110 | private void SendMpBeatmapPacket(BeatmapKey beatmapKey, IConnectedPlayer? player = null) 111 | { 112 | var levelId = beatmapKey.levelId; 113 | _logger.Debug($"Sending beatmap packet for level {levelId}"); 114 | 115 | var levelHash = Utilities.HashForLevelID(levelId); 116 | if (levelHash == null) 117 | { 118 | _logger.Debug("Not a custom level, returning..."); 119 | return; 120 | } 121 | 122 | var levelData = _beatmapLevelProvider.GetBeatmapFromLocalBeatmaps(levelHash); 123 | var packet = (levelData != null) ? new MpBeatmapPacket(levelData, beatmapKey) : FindLevelPacket(levelHash); 124 | if (packet == null) 125 | { 126 | _logger.Warn($"Could not get level data for beatmap '{levelHash}', returning!"); 127 | return; 128 | } 129 | 130 | if (player != null) 131 | _multiplayerSessionManager.SendToPlayer(packet, player); 132 | else 133 | _multiplayerSessionManager.Send(packet); 134 | } 135 | 136 | public MpBeatmapPacket? GetPlayerPacket(string playerId) 137 | { 138 | _lastPlayerBeatmapPackets.TryGetValue(playerId, out var packet); 139 | _logger.Debug($"Got player packet for {playerId} with levelHash: {packet?.levelHash ?? "NULL"}"); 140 | return packet; 141 | } 142 | 143 | private void PutPlayerPacket(string playerId, MpBeatmapPacket packet) 144 | { 145 | _logger.Debug($"Putting packet for player {playerId} with levelHash: {packet.levelHash}"); 146 | _lastPlayerBeatmapPackets[playerId] = packet; 147 | } 148 | 149 | public MpBeatmapPacket? FindLevelPacket(string levelHash) 150 | { 151 | var packet = _lastPlayerBeatmapPackets.Values.FirstOrDefault(packet => packet.levelHash == levelHash); 152 | _logger.Debug($"Found packet: {packet?.levelHash ?? "NULL"}"); 153 | return packet; 154 | } 155 | 156 | public MpBeatmap GetLevelLocalOrFromPacketOrDummy(string levelHash) 157 | { 158 | var level = _beatmapLevelProvider.GetBeatmapFromLocalBeatmaps(levelHash); 159 | if (level == null) 160 | { 161 | var packet = FindLevelPacket(levelHash); 162 | if (packet != null) level = _beatmapLevelProvider.GetBeatmapFromPacket(packet); 163 | } 164 | if (level == null) level = new NoInfoBeatmapLevel(levelHash); 165 | return level; 166 | } 167 | 168 | } 169 | } 170 | --------------------------------------------------------------------------------