├── .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) [](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 |
--------------------------------------------------------------------------------