From 6f3fb4000159771325fa71209c0a9d0c35312ee9 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 02:07:44 +0400 Subject: [PATCH 01/15] Fix per player modifiers infinite loop and prevent modifier reset if the lobby owner returns to the lobby --- src/UI/MpPerPlayerUI.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/UI/MpPerPlayerUI.cpp b/src/UI/MpPerPlayerUI.cpp index a36ae55..7280230 100644 --- a/src/UI/MpPerPlayerUI.cpp +++ b/src/UI/MpPerPlayerUI.cpp @@ -46,6 +46,9 @@ std::future finished_future(T& value) { return p.get_future(); } namespace MultiplayerCore::UI { + // Setting toggle triggers setters, guard every set_Value with this to prevent unwanted state resets + bool skipUpdateHandler = false; + void MpPerPlayerUI::ctor( GlobalNamespace::GameServerLobbyFlowCoordinator* gameServerLobbyFlowCoordinator, GlobalNamespace::BeatmapLevelsModel* beatmapLevelsModel, @@ -166,8 +169,10 @@ namespace MultiplayerCore::UI { if (addedToHierarchy) { // Reset our buttons + skipUpdateHandler = true; // set_Value triggers on update ppdt->set_Value(false); ppmt->set_Value(false); + skipUpdateHandler = false; // Request Updated state _multiplayerSessionManager->Send(Players::Packets::GetMpPerPlayerPacket::New_ctor()); @@ -225,8 +230,10 @@ namespace MultiplayerCore::UI { // Check if player is party Owner if (ppdt && ppmt && player->get_isConnectionOwner() && (packet->PPDEnabled != ppdt->get_Value() || packet->PPMEnabled != ppmt->get_Value())) { DEBUG("Player is Connection Owner, updating toogle values"); + skipUpdateHandler = true; ppdt->set_Value(packet->PPDEnabled); ppmt->set_Value(packet->PPMEnabled); + skipUpdateHandler = false; } else if (!player->get_isConnectionOwner()) { WARNING("Player is not Connection Owner, ignoring packet"); } @@ -463,6 +470,7 @@ namespace MultiplayerCore::UI { } void MpPerPlayerUI::set_PerPlayerDifficulty(bool value) { + if (skipUpdateHandler) return; // set_PerPlayerDifficulty if (ppdt) ppdt->set_Value(value); // Send updated MpPerPlayerPacket @@ -478,6 +486,7 @@ namespace MultiplayerCore::UI { } void MpPerPlayerUI::set_PerPlayerModifiers(bool value) { + if (skipUpdateHandler) return; // set_PerPlayerModifiers if (ppmt) ppmt->set_Value(value); // Send updated MpPerPlayerPacket From 1dd1760071d3cd9a210287d96c2202a22c5a6b08 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 02:24:44 +0400 Subject: [PATCH 02/15] FindLevelPacket fix --- src/Objects/MpPlayersDataModel.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Objects/MpPlayersDataModel.cpp b/src/Objects/MpPlayersDataModel.cpp index 07697e3..37368a2 100644 --- a/src/Objects/MpPlayersDataModel.cpp +++ b/src/Objects/MpPlayersDataModel.cpp @@ -124,7 +124,6 @@ namespace MultiplayerCore::Objects { Beatmaps::Packets::MpBeatmapPacket* MpPlayersDataModel::FindLevelPacket(std::string_view levelHash) { Beatmaps::Packets::MpBeatmapPacket* packet = nullptr; - return packet; auto enumerator = _lastPlayerBeatmapPackets->GetEnumerator(); while (enumerator.MoveNext()) { auto [_, p] = enumerator.Current; From 53b93a075910d0033294d077422c5c5ec8827d07 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 02:26:37 +0400 Subject: [PATCH 03/15] Fix undefined behavior in BeatSaverBeatmapLevel::Make --- src/Beatmaps/BeatSaverBeatmapLevel.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Beatmaps/BeatSaverBeatmapLevel.cpp b/src/Beatmaps/BeatSaverBeatmapLevel.cpp index d62111e..30bfd4d 100644 --- a/src/Beatmaps/BeatSaverBeatmapLevel.cpp +++ b/src/Beatmaps/BeatSaverBeatmapLevel.cpp @@ -55,14 +55,15 @@ namespace MultiplayerCore::Beatmaps { nullptr ); - auto v = std::find_if(level->beatmap.GetVersions().begin(), level->beatmap.GetVersions().end(), + auto versions = level->beatmap.GetVersions(); + auto v = std::find_if(versions.begin(), versions.end(), [hash = std::string_view(hash)](auto& x){ return std::ranges::equal(x.GetHash(), hash, [](char a, char b){ return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); }); } ); - if (v != level->beatmap.GetVersions().end()) { + if (v != versions.end()) { if (v->GetDiffs().empty()) { WARNING("No difficulties found for hash {} using BPP, this should not happen!", hash); } @@ -79,10 +80,10 @@ namespace MultiplayerCore::Beatmaps { else { WARNING("Could not find version for hash {}", hash); // Using latest to get the difficulties - if (level->beatmap.GetVersions().front().GetDiffs().empty()) { + if (versions.front().GetDiffs().empty()) { WARNING("No difficulties found for hash {} using BPP, this should not happen!", hash); } - for (const auto& difficulty : level->beatmap.GetVersions().front().GetDiffs()) { + for (const auto& difficulty : versions.front().GetDiffs()) { DEBUG("Adding difficulty {} to characteristic {}", difficulty.GetDifficulty(), difficulty.GetCharacteristic()); auto& list = level->requirements[difficulty.GetCharacteristic()][ConvertDifficulty(difficulty.GetDifficulty())]; } From 809c4ab57f7a6dd3781c2bcfee55659214d59ee9 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 02:28:01 +0400 Subject: [PATCH 04/15] Fix Injection of GameServerPlayerTableCellCustomData happening too late --- src/Hooks/GameServerPlayerTableCellHooks.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Hooks/GameServerPlayerTableCellHooks.cpp b/src/Hooks/GameServerPlayerTableCellHooks.cpp index 792ff81..59a25c9 100644 --- a/src/Hooks/GameServerPlayerTableCellHooks.cpp +++ b/src/Hooks/GameServerPlayerTableCellHooks.cpp @@ -8,6 +8,7 @@ #include "UnityEngine/GameObject.hpp" #include "UI/GameServerPlayerTableCellCustomData.hpp" #include "Utilities.hpp" +#include "bsml/shared/Helpers/getters.hpp" // does not call orig MAKE_AUTO_HOOK_ORIG_MATCH( @@ -34,6 +35,13 @@ MAKE_AUTO_HOOK_ORIG_MATCH( return GameServerPlayerTableCell_SetData(self, connectedPlayer, playerData, hasKickPermissions, allowSelection, getLevelEntitlementTask); } customData->Awake(); + // Injection does not happen after awake here, so we need to manually trigger injection + auto diContainer = BSML::Helpers::GetDiContainer(); + if (!diContainer) { + ERROR("Failed to get DiContainer. This should never happen, set data will likely fail the first time"); + } else { + diContainer->Inject(customData); + } return customData->SetData(connectedPlayer, playerData, hasKickPermissions, allowSelection, getLevelEntitlementTask); } } @@ -42,6 +50,15 @@ MAKE_AUTO_HOOK_ORIG_MATCH( // WARNING, this only applies to cells of other players not the local player MAKE_AUTO_HOOK_MATCH(GameServerPlayersTableView_GetCurrentPrefab, &GlobalNamespace::GameServerPlayersTableView::GetCurrentPrefab, UnityW, GlobalNamespace::GameServerPlayersTableView* self) { auto res = GameServerPlayersTableView_GetCurrentPrefab(self); - if (!res->GetComponent()) res->gameObject->AddComponent(); + if (!res->GetComponent()) { + auto customData = res->gameObject->AddComponent(); + // Injection does not happen after awake here, so we need to manually trigger injection + auto diContainer = BSML::Helpers::GetDiContainer(); + if (!diContainer) { + ERROR("Failed to get DiContainer. This should never happen, calling orig and praying"); + } else { + diContainer->Inject(customData); + } + }; return res; } \ No newline at end of file From 8fc076fc0b6308a67b20659ba48991376e967ea9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 9 Apr 2026 02:52:40 +0400 Subject: [PATCH 05/15] Update src/Hooks/GameServerPlayerTableCellHooks.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/Hooks/GameServerPlayerTableCellHooks.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Hooks/GameServerPlayerTableCellHooks.cpp b/src/Hooks/GameServerPlayerTableCellHooks.cpp index 59a25c9..71850b0 100644 --- a/src/Hooks/GameServerPlayerTableCellHooks.cpp +++ b/src/Hooks/GameServerPlayerTableCellHooks.cpp @@ -52,6 +52,10 @@ MAKE_AUTO_HOOK_MATCH(GameServerPlayersTableView_GetCurrentPrefab, &GlobalNamespa auto res = GameServerPlayersTableView_GetCurrentPrefab(self); if (!res->GetComponent()) { auto customData = res->gameObject->AddComponent(); + if (!customData) { + ERROR("Failed to add custom data component to prefab"); + return res; + } // Injection does not happen after awake here, so we need to manually trigger injection auto diContainer = BSML::Helpers::GetDiContainer(); if (!diContainer) { From c3b372c48d2a3e201a532c873e5c1ac29109b7b6 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 03:16:09 +0400 Subject: [PATCH 06/15] Sort difficulties in selector --- src/UI/MpPerPlayerUI.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/UI/MpPerPlayerUI.cpp b/src/UI/MpPerPlayerUI.cpp index 7280230..901c43b 100644 --- a/src/UI/MpPerPlayerUI.cpp +++ b/src/UI/MpPerPlayerUI.cpp @@ -37,6 +37,9 @@ #include "assets.hpp" #include "logging.hpp" +#include +#include + DEFINE_TYPE(MultiplayerCore::UI, MpPerPlayerUI); template @@ -384,9 +387,6 @@ namespace MultiplayerCore::UI { for (const auto& [key, value] : reqCharItr->second) { difficulties->Add(Utils::EnumUtils::GetEnumName(key)); } - // std::transform(reqCharItr->second.begin(), reqCharItr->second.end(), difficulties.begin(), [](const auto& pair) { - // return Utils::EnumUtils::GetEnumName(pair.first); - // }); UpdateDifficultyList(difficulties); }); } @@ -438,6 +438,24 @@ namespace MultiplayerCore::UI { return; } + // Sort difficulties from Easy to ExpertPlus by enum value + std::vector sortedDifficulties(_allowedDifficulties->Count); + for (int i = 0; i < _allowedDifficulties->Count; i++) { + sortedDifficulties[i] = static_cast(_allowedDifficulties->get_Item(i)); + } + std::sort(sortedDifficulties.begin(), sortedDifficulties.end(), [](const std::string& a, const std::string& b) { + std::string aNorm = a, bNorm = b; + if (aNorm == "Expert+") aNorm = "ExpertPlus"; + if (bNorm == "Expert+") bNorm = "ExpertPlus"; + auto aVal = Utils::EnumUtils::GetEnumValue(aNorm); + auto bVal = Utils::EnumUtils::GetEnumValue(bNorm); + return aVal.value__ < bVal.value__; + }); + _allowedDifficulties->Clear(); + for (auto& diff : sortedDifficulties) { + _allowedDifficulties->Add(diff); + } + // Updating UI has to be done on the main thread BSML::MainThreadScheduler::Schedule([this](){ if (_allowedDifficulties->Count > 1) { From 61f549f9f5b99575d4f80bb09cc9553c7a4f1a9e Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 03:18:05 +0400 Subject: [PATCH 07/15] Add normal scripts --- scripts/build.ps1 | 31 ++++++++++++++ scripts/copy.ps1 | 79 ++++++++++++++++++++++++++++++++++++ scripts/createqmod.ps1 | 77 +++++++++++++++++++++++++++++++++++ scripts/pull-tombstone.ps1 | 52 ++++++++++++++++++++++++ scripts/restart-game.ps1 | 2 + scripts/start-logging.ps1 | 75 ++++++++++++++++++++++++++++++++++ scripts/validate-modjson.ps1 | 38 +++++++++++++++++ 7 files changed, 354 insertions(+) create mode 100644 scripts/build.ps1 create mode 100644 scripts/copy.ps1 create mode 100644 scripts/createqmod.ps1 create mode 100644 scripts/pull-tombstone.ps1 create mode 100644 scripts/restart-game.ps1 create mode 100644 scripts/start-logging.ps1 create mode 100644 scripts/validate-modjson.ps1 diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..fe746c3 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,31 @@ +Param( + [Parameter(Mandatory=$false)] + [Switch] $clean, + + [Parameter(Mandatory=$false)] + [Switch] $help +) + +if ($help -eq $true) { + Write-Output "`"Build`" - Copiles your mod into a `".so`" or a `".a`" library" + Write-Output "`n-- Arguments --`n" + + Write-Output "-Clean `t`t Deletes the `"build`" folder, so that the entire library is rebuilt" + + exit +} + +# if user specified clean, remove all build files +if ($clean.IsPresent) { + if (Test-Path -Path "build") { + remove-item build -R + } +} + + +if (($clean.IsPresent) -or (-not (Test-Path -Path "build"))) { + new-item -Path build -ItemType Directory +} + +& cmake -G "Ninja" -DCMAKE_BUILD_TYPE="RelWithDebInfo" -B build +& cmake --build ./build diff --git a/scripts/copy.ps1 b/scripts/copy.ps1 new file mode 100644 index 0000000..a41ed95 --- /dev/null +++ b/scripts/copy.ps1 @@ -0,0 +1,79 @@ +Param( + [Parameter(Mandatory=$false)] + [Switch] $clean, + + [Parameter(Mandatory=$false)] + [Switch] $log, + + [Parameter(Mandatory=$false)] + [Switch] $useDebug, + + [Parameter(Mandatory=$false)] + [Switch] $self, + + [Parameter(Mandatory=$false)] + [Switch] $all, + + [Parameter(Mandatory=$false)] + [String] $custom="", + + [Parameter(Mandatory=$false)] + [Switch] $file, + + [Parameter(Mandatory=$false)] + [Switch] $help +) + +if ($help -eq $true) { + Write-Output "`"Copy`" - Builds and copies your mod to your quest, and also starts Beat Saber with optional logging" + Write-Output "`n-- Arguments --`n" + + Write-Output "-Clean `t`t Performs a clean build (equvilant to running `"build -clean`")" + Write-Output "-UseDebug `t Copies the debug version of the mod to your quest" + Write-Output "-Log `t`t Logs Beat Saber using the `"Start-Logging`" command" + + Write-Output "`n-- Logging Arguments --`n" + + & $PSScriptRoot/start-logging.ps1 -help -excludeHeader + + exit +} + +& $PSScriptRoot/build.ps1 -clean:$clean + +if ($LASTEXITCODE -ne 0) { + Write-Output "Failed to build, exiting..." + exit $LASTEXITCODE +} + +& $PSScriptRoot/validate-modjson.ps1 +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +$modJson = Get-Content "./mod.json" -Raw | ConvertFrom-Json + +$modFiles = $modJson.modFiles +$lateModFiles = $modJson.lateModFiles + +foreach ($fileName in $modFiles) { + if ($useDebug -eq $true) { + & adb push build/debug/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/early_mods/$fileName + } else { + & adb push build/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/early_mods/$fileName + } +} + +foreach ($fileName in $lateModFiles) { + if ($useDebug -eq $true) { + & adb push build/debug/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/mods/$fileName + } else { + & adb push build/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/mods/$fileName + } +} + +& $PSScriptRoot/restart-game.ps1 + +if ($log -eq $true) { + & adb logcat -c + & $PSScriptRoot/start-logging.ps1 -self:$self -all:$all -custom:$custom -file:$file +} diff --git a/scripts/createqmod.ps1 b/scripts/createqmod.ps1 new file mode 100644 index 0000000..95955bd --- /dev/null +++ b/scripts/createqmod.ps1 @@ -0,0 +1,77 @@ +Param( + [Parameter(Mandatory=$false)] + [String] $qmodName="", + + [Parameter(Mandatory=$false)] + [Switch] $help +) + +if ($help -eq $true) { + Write-Output "`"createqmod`" - Creates a .qmod file with your compiled libraries and mod.json." + Write-Output "`n-- Arguments --`n" + + Write-Output "-QmodName `t The file name of your qmod" + + exit +} + +$mod = "./mod.json" + +& $PSScriptRoot/validate-modjson.ps1 +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +$modJson = Get-Content $mod -Raw | ConvertFrom-Json + +if ($qmodName -eq "") { + $qmodName = $modJson.name +} + +$filelist = @($mod) + +$cover = "./" + $modJson.coverImage +if ((-not ($cover -eq "./")) -and (Test-Path $cover)) { + $filelist += ,$cover +} + +foreach ($mod in $modJson.modFiles) { + $path = "./build/" + $mod + if (-not (Test-Path $path)) { + $path = "./extern/libs/" + $mod + } + if (-not (Test-Path $path)) { + Write-Output "Error: could not find dependency: $path" + exit 1 + } + $filelist += $path +} + +foreach ($mod in $modJson.lateModFiles) { + $path = "./build/" + $mod + if (-not (Test-Path $path)) { + $path = "./extern/libs/" + $mod + } + if (-not (Test-Path $path)) { + Write-Output "Error: could not find dependency: $path" + exit 1 + } + $filelist += $path +} + +foreach ($lib in $modJson.libraryFiles) { + $path = "./build/" + $lib + if (-not (Test-Path $path)) { + $path = "./extern/libs/" + $lib + } + if (-not (Test-Path $path)) { + Write-Output "Error: could not find dependency: $path" + exit 1 + } + $filelist += $path +} + +$zip = $qmodName + ".zip" +$qmod = $qmodName + ".qmod" + +Compress-Archive -Path $filelist -DestinationPath $zip -Update +Move-Item $zip $qmod -Force diff --git a/scripts/pull-tombstone.ps1 b/scripts/pull-tombstone.ps1 new file mode 100644 index 0000000..10fbc24 --- /dev/null +++ b/scripts/pull-tombstone.ps1 @@ -0,0 +1,52 @@ +Param( + [Parameter(Mandatory=$false)] + [String] $fileName = "RecentCrash.log", + + [Parameter(Mandatory=$false)] + [Switch] $analyze, + + [Parameter(Mandatory=$false)] + [Switch] $help +) + +if ($help -eq $true) { + Write-Output "`"Pull-Tombstone`" - Finds and pulls the most recent tombstone from your quest, optionally analyzing it with ndk-stack" + Write-Output "`n-- Arguments --`n" + + Write-Output "-FileName `t The name for the output file, defaulting to RecentCrash.log" + Write-Output "-Analyze `t Runs ndk-stack on the file after pulling" + + exit +} + +$global:currentDate = get-date +$global:recentDate = $Null +$global:recentTombstone = $Null + +for ($i = 0; $i -lt 3; $i++) { + $stats = & adb shell stat /sdcard/Android/data/com.beatgames.beatsaber/files/tombstone_0$i + $date = (Select-String -Input $stats -Pattern "(?<=Modify: )\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?=.\d{9})").Matches.Value + if([string]::IsNullOrEmpty($date)) { + Write-Output "Failed to pull tombstone, exiting..." + exit 1; + } + $dateObj = [datetime]::ParseExact($date, "yyyy-MM-dd HH:mm:ss", $Null) + $difference = [math]::Round(($currentDate - $dateObj).TotalMinutes) + if ($difference -eq 1) { + Write-Output "Found tombstone_0$i $difference minute ago" + } else { + Write-Output "Found tombstone_0$i $difference minutes ago" + } + if (-not $recentDate -or $recentDate -lt $dateObj) { + $recentDate = $dateObj + $recentTombstone = $i + } +} + +Write-Output "Latest tombstone was tombstone_0$recentTombstone" + +& adb pull /sdcard/Android/data/com.beatgames.beatsaber/files/tombstone_0$recentTombstone $fileName + +if ($analyze) { + & $PSScriptRoot/ndk-stack.ps1 -logName:$fileName +} diff --git a/scripts/restart-game.ps1 b/scripts/restart-game.ps1 new file mode 100644 index 0000000..fd0196a --- /dev/null +++ b/scripts/restart-game.ps1 @@ -0,0 +1,2 @@ +adb shell am force-stop com.beatgames.beatsaber +adb shell am start com.beatgames.beatsaber/com.unity3d.player.UnityPlayerActivity diff --git a/scripts/start-logging.ps1 b/scripts/start-logging.ps1 new file mode 100644 index 0000000..42adfad --- /dev/null +++ b/scripts/start-logging.ps1 @@ -0,0 +1,75 @@ +Param( + [Parameter(Mandatory=$false)] + [Switch] $self, + + [Parameter(Mandatory=$false)] + [Switch] $all, + + [Parameter(Mandatory=$false)] + [String] $custom="", + + [Parameter(Mandatory=$false)] + [Switch] $file="", + + [Parameter(Mandatory=$false)] + [Switch] $help, + + [Parameter(Mandatory=$false)] + [Switch] $excludeHeader +) + +if ($help -eq $true) { + if ($excludeHeader -eq $false) { + Write-Output "`"Start-Logging`" - Logs Beat Saber using `"adb logcat`"" + Write-Output "`n-- Arguments --`n" + } + + Write-Output "-Self `t`t Only Logs your mod and Crashes" + Write-Output "-All `t`t Logs everything, including logs made by the Quest itself" + Write-Output "-Custom `t Specify a specific logging pattern, e.g `"custom-types|questui`"" + Write-Output "`t`t NOTE: The paterent `"AndriodRuntime|CRASH`" is always appended to a custom pattern" + Write-Output "-File `t`t Saves the output of the log to the file name given" + + exit +} + +$bspid = adb shell pidof com.beatgames.beatsaber +$command = "adb logcat " + +# if ($all -eq $false) { + $loops = 0 + while ([string]::IsNullOrEmpty($bspid) -and $loops -lt 6) { + Start-Sleep -Milliseconds 100 + $bspid = adb shell pidof com.beatgames.beatsaber + $loops += 1 + } + + if ([string]::IsNullOrEmpty($bspid)) { + Write-Output "Could not connect to adb, exiting..." + exit 1 + } + + $command += "--pid $bspid" +# } + +if ($all -eq $false) { + $pattern = "(" + if ($self -eq $true) { + $pattern += "MultiplayerCore|" + } + if (![string]::IsNullOrEmpty($custom)) { + $pattern += "$custom|" + } + if ($pattern -eq "(") { + $pattern = "(QuestHook|modloader|" + } + $pattern += "AndroidRuntime|CRASH)" + $command += " | Select-String -pattern `"$pattern`"" +} + +if ($file -eq $true) { + $command += " | Out-File -FilePath $PSScriptRoot\..\log.log" +} + +Write-Output "Logging using Command `"$command`"" +Invoke-Expression $command diff --git a/scripts/validate-modjson.ps1 b/scripts/validate-modjson.ps1 new file mode 100644 index 0000000..f294752 --- /dev/null +++ b/scripts/validate-modjson.ps1 @@ -0,0 +1,38 @@ +$mod = "./mod.json" + +if (-not (Test-Path -Path $mod)) { + if (Test-Path -Path ".\mod.template.json") { + & qpm qmod build + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } + else { + Write-Output "Error: mod.json and mod.template.json were not present" + exit 1 + } +} + +Write-Output "Creating qmod from mod.json" + +$psVersion = $PSVersionTable.PSVersion.Major +if ($psVersion -ge 6) { + $schemaUrl = "https://raw.githubusercontent.com/Lauriethefish/QuestPatcher.QMod/main/QuestPatcher.QMod/Resources/qmod.schema.json" + Invoke-WebRequest $schemaUrl -OutFile ./mod.schema.json + + $schema = "./mod.schema.json" + $modJsonRaw = Get-Content $mod -Raw + $modSchemaRaw = Get-Content $schema -Raw + + Remove-Item $schema + + Write-Output "Validating mod.json..." + if (-not ($modJsonRaw | Test-Json -Schema $modSchemaRaw)) { + Write-Output "Error: mod.json is not valid" + exit 1 + } +} +else { + Write-Output "Could not validate mod.json with schema: powershell version was too low (< 6)" +} +exit From 6ca0850e9e4bff93638d94a51f554c97d963e16b Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 03:32:47 +0400 Subject: [PATCH 08/15] Fix incorrect guard in UnloadLevelIfRequirementsNotMet --- src/Objects/MpLevelLoader.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Objects/MpLevelLoader.cpp b/src/Objects/MpLevelLoader.cpp index c4586f6..1710470 100644 --- a/src/Objects/MpLevelLoader.cpp +++ b/src/Objects/MpLevelLoader.cpp @@ -127,7 +127,7 @@ namespace MultiplayerCore::Objects { // SongCore::CustomJSONData::CustomSaveDataInfo::BasicCustomLevelDetails auto diffDataOpt = level->standardLevelInfoSaveDataV2.value()->CustomSaveDataInfo->get().TryGetCharacteristicAndDifficulty(beatmapKey.beatmapCharacteristic->serializedName, beatmapKey.difficulty); - if (diffDataOpt.has_value()) return; + if (!diffDataOpt.has_value()) return; auto& diffData = diffDataOpt->get(); bool requirementsMet = true; From 36ff6fe35a881d87b198cf658dd55f3a53c86fe9 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 03:51:03 +0400 Subject: [PATCH 09/15] Fix colors deserialization --- src/Beatmaps/Abstractions/DifficultyColors.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Beatmaps/Abstractions/DifficultyColors.cpp b/src/Beatmaps/Abstractions/DifficultyColors.cpp index f0259f8..4faaf15 100644 --- a/src/Beatmaps/Abstractions/DifficultyColors.cpp +++ b/src/Beatmaps/Abstractions/DifficultyColors.cpp @@ -23,12 +23,12 @@ namespace MultiplayerCore::Beatmaps::Abstractions { void DifficultyColors::Deserialize(LiteNetLib::Utils::NetDataReader* reader) { uint8_t colors = reader->GetByte(); - if (((colors << 0) & 0x1) != 0) colorLeft = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 1) & 0x1) != 0) colorRight = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 2) & 0x1) != 0) envColorLeft = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 3) & 0x1) != 0) envColorRight = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 4) & 0x1) != 0) envColorLeftBoost = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 5) & 0x1) != 0) envColorRightBoost = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); - if (((colors << 6) & 0x1) != 0) obstacleColor = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 0) & 0x1) != 0) colorLeft = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 1) & 0x1) != 0) colorRight = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 2) & 0x1) != 0) envColorLeft = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 3) & 0x1) != 0) envColorRight = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 4) & 0x1) != 0) envColorLeftBoost = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 5) & 0x1) != 0) envColorRightBoost = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); + if (((colors >> 6) & 0x1) != 0) obstacleColor = MultiplayerCore::Utils::ExtraSongData::MapColor(reader); } } From 0f3bb2668949f18c113601955f5b76b539ed564c Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:24:05 +0400 Subject: [PATCH 10/15] Make sure we don't sleep if the cover image fails to load --- src/Beatmaps/BeatSaverPreviewMediaData.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Beatmaps/BeatSaverPreviewMediaData.cpp b/src/Beatmaps/BeatSaverPreviewMediaData.cpp index 69a74db..6f3ff0c 100644 --- a/src/Beatmaps/BeatSaverPreviewMediaData.cpp +++ b/src/Beatmaps/BeatSaverPreviewMediaData.cpp @@ -1,4 +1,5 @@ #include "Beatmaps/BeatSaverPreviewMediaData.hpp" +#include "logging.hpp" #include "tasks.hpp" #include "UnityEngine/Networking/UnityWebRequestMultimedia.hpp" @@ -46,13 +47,22 @@ namespace MultiplayerCore::Beatmaps { auto coverOpt = v->GetCoverImage(); if (coverOpt.has_value()) { auto coverBytes = ArrayW(coverOpt.value()); - + + std::atomic done = false; + // TODO: Maybe use safeptr or something to make sure it can't get GC'd std::atomic result = nullptr; - BSML::MainThreadScheduler::Schedule([coverBytes, &result](){ - result = BSML::Utilities::LoadSpriteRaw(coverBytes); + BSML::MainThreadScheduler::Schedule([coverBytes, &result, &done](){ + try { + result = BSML::Utilities::LoadSpriteRaw(coverBytes); + done = true; + } catch (...) { + ERROR("Exception while loading cover image"); + result = nullptr; + done = true; + } }); - while(!result) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + while(!done) std::this_thread::sleep_for(std::chrono::milliseconds(100)); return (UnityEngine::Sprite*)result; } } From 1d43f488e2681be9b85d4d1b509cd62ffee26944 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:24:54 +0400 Subject: [PATCH 11/15] Fix required mod display --- src/Hooks/MultiplayerUnavailableReasonHooks.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hooks/MultiplayerUnavailableReasonHooks.cpp b/src/Hooks/MultiplayerUnavailableReasonHooks.cpp index daaf860..da46b7c 100644 --- a/src/Hooks/MultiplayerUnavailableReasonHooks.cpp +++ b/src/Hooks/MultiplayerUnavailableReasonHooks.cpp @@ -61,7 +61,7 @@ MAKE_AUTO_HOOK_MATCH(MultiplayerUnavailableReasonMethods_TryGetMultiplayerUnavai reason.heldRef = GlobalNamespace::MultiplayerUnavailableReason(5); ::requiredMod = installedModInfo.id; - ::requiredVersion = installedModInfo.version; + ::requiredVersion = req.version; return true; } From 15da17f4a6f6937af6a2ba501225ae82b6fe4550 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:26:02 +0400 Subject: [PATCH 12/15] Ensure matching case in FindLevelPacket --- src/Objects/MpPlayersDataModel.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Objects/MpPlayersDataModel.cpp b/src/Objects/MpPlayersDataModel.cpp index 37368a2..7298824 100644 --- a/src/Objects/MpPlayersDataModel.cpp +++ b/src/Objects/MpPlayersDataModel.cpp @@ -123,6 +123,10 @@ namespace MultiplayerCore::Objects { } Beatmaps::Packets::MpBeatmapPacket* MpPlayersDataModel::FindLevelPacket(std::string_view levelHash) { + // Uppercase the hash we get just in case + auto upperHash = std::string(levelHash); + std::transform(upperHash.begin(), upperHash.end(), upperHash.begin(), toupper); + Beatmaps::Packets::MpBeatmapPacket* packet = nullptr; auto enumerator = _lastPlayerBeatmapPackets->GetEnumerator(); while (enumerator.MoveNext()) { @@ -130,7 +134,7 @@ namespace MultiplayerCore::Objects { std::string packetHash(p->levelHash); DEBUG("Found packetHash {}", packetHash); std::transform(packetHash.begin(), packetHash.end(), packetHash.begin(), toupper); - if (packetHash == levelHash) { + if (packetHash == upperHash) { packet = p; break; } From b8417c93b0b587d1807a1f48120e3bd65f3aa963 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:26:17 +0400 Subject: [PATCH 13/15] Fix invalid logs --- src/MethodPatches/RemoveEmptySlotIfEvenPlayersPatches.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MethodPatches/RemoveEmptySlotIfEvenPlayersPatches.cpp b/src/MethodPatches/RemoveEmptySlotIfEvenPlayersPatches.cpp index 8e90624..ac588d8 100644 --- a/src/MethodPatches/RemoveEmptySlotIfEvenPlayersPatches.cpp +++ b/src/MethodPatches/RemoveEmptySlotIfEvenPlayersPatches.cpp @@ -131,7 +131,7 @@ void Patch_MultiplayerLobbyAvatarManager_AddPlayer() { DEBUG("Found 1st and @ {} (offset {:x})", fmt::ptr(ins), ((uintptr_t)ins) - il2cpp_base); DEBUG("Instruction before edit: {:x}", *ins); if (*ins == 0x12000129u) { // check for 'and w9, w9, 0x1' - if (set_ins(ins, 0x52800009u)) { // mov w9, 0x0 + if (!set_ins(ins, 0x52800009u)) { // mov w9, 0x0 ERROR("Failed to set instruction in MultiplayerLobbyAvatarManager_AddPlayer"); } } else { From 83bd617967bb77672aa7a08dc0e1ee051297b464 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:53:17 +0400 Subject: [PATCH 14/15] Fix multiplayer unavailable reason handling for missing required mods --- src/Hooks/MultiplayerUnavailableReasonHooks.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Hooks/MultiplayerUnavailableReasonHooks.cpp b/src/Hooks/MultiplayerUnavailableReasonHooks.cpp index da46b7c..0428bac 100644 --- a/src/Hooks/MultiplayerUnavailableReasonHooks.cpp +++ b/src/Hooks/MultiplayerUnavailableReasonHooks.cpp @@ -50,6 +50,7 @@ MAKE_AUTO_HOOK_MATCH(MultiplayerUnavailableReasonMethods_TryGetMultiplayerUnavai if (!mpData->RequiredMods.empty()) { for (const auto& req : mpData->RequiredMods) { if (!req.required) continue; + // TODO: If mod is not installed at all and is required, we probably should also return a multiplayer unavailable reason for that auto installedMod = find_mod(req.id); if (!installedMod) continue; @@ -82,7 +83,7 @@ MAKE_AUTO_HOOK_MATCH(MultiplayerUnavailableReasonMethods_LocalizedKey, &::Global switch(multiplayerUnavailableReason.value__) { case 5: { // a mod too old auto installedMod = find_mod(requiredMod); - return fmt::format("Multiplayer Unavailable\nMod {} is out of date.\nPlease update to version {} or newer", installedMod->info.version, requiredVersion); + return fmt::format("Multiplayer Unavailable\nMod {} is out of date.\nPlease update to version {} or newer", requiredMod, requiredVersion); } break; case 6: { // game too new return fmt::format("Multiplayer Unavailable\nBeat Saber version is too new\nMaximum version: {}\nCurrent version: {}", maximumBsVersion, UnityEngine::Application::get_version()); From e356c0f365f1fba59cc353dd73620de281587a22 Mon Sep 17 00:00:00 2001 From: Alex Uskov Date: Thu, 9 Apr 2026 04:54:02 +0400 Subject: [PATCH 15/15] Fix infinite wait if failed to load audio clip --- src/Beatmaps/BeatSaverPreviewMediaData.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Beatmaps/BeatSaverPreviewMediaData.cpp b/src/Beatmaps/BeatSaverPreviewMediaData.cpp index 6f3ff0c..5204073 100644 --- a/src/Beatmaps/BeatSaverPreviewMediaData.cpp +++ b/src/Beatmaps/BeatSaverPreviewMediaData.cpp @@ -98,11 +98,13 @@ namespace MultiplayerCore::Beatmaps { auto www = webRequest->SendWebRequest(); while (!www->get_isDone()) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::atomic done = false; std::atomic result = nullptr; - BSML::MainThreadScheduler::Schedule([webRequest, &result](){ + BSML::MainThreadScheduler::Schedule([webRequest, &result, &done](){ result = UnityEngine::Networking::DownloadHandlerAudioClip::GetContent(webRequest); + done = true; }); - while (!result) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + while (!done) std::this_thread::sleep_for(std::chrono::milliseconds(100)); return (UnityEngine::AudioClip*)result; }