A custom song patcher for Beat Saber on the Oculus Quest, written in C# and runnable cross-platform on macOS, Windows and Linux. It can successfully add custom songs to the Quest as well as create custom packs and saber colors with the advanced JSON interface. Based on discoveries both of my own as well as many discoveries made by @emulamer (his code was also an excellent reference!), with lots of code contributed by @sc2ad, and information from others on the Beat Saber modding Discord.
It can patch a Beat Saber APK with new custom levels, as well as patch the binary to not check the signature on levels. It's very similar to emulamer's patcher with some extra features and some missing features, what specific features are different changes very frequently and I can't hope to keep the readme completely up to date. Here are some of the features of QuestSaberPatch:
QuestSaberPatch is the backend for the Beat Saber modding support in SideQuest. I highly recommend SideQuest as the preferred way of using QuestSaberPatch. It's what I personally use.
If for some reason you can't or don't want to use SideQuest, you can use the command line version of QuestSaberPatch by following these instructions.
Don't ask me for help with this process unless you're a programmer looking to integrate QuestSaberPatch into a GUI. While it has progressed from programmers-only to power-users-only it's still not an easy process. You at least need to be comfortable with the command line. If anything goes wrong while you try to follow this process, I disclaim liability, do this at your own risk.
dotnet run -p app/app.csproj <path to APK to modify> <paths to folder with levels to add>...
.
app
executable instead of the dotnet run -p app/app.csproj
part.adb install -r {patched apk path}
to install the patched signed new APK that the signer creates replacing the old APK.adb
: https://developer.android.com/studio/command-line/adbadb pull /data/app/com.beatgames.beatsaber-1/base.apk {location you want to put it}
to grab the APK off the deviceadb pull
to grab them off the device. Look in /sdcard/Android/obb/com.beatgames.beatsaber/
for DLC downloads and make sure to get /sdcard/Android/data/com.beatgames.beatsaber/files/PlayerData.dat
(local scores) and /sdcard/Android/data/com.beatgames.beatsaber/files/settings.cfg
(settings). Keep track of where each file you pull came from!adb uninstall com.beatgames.beatsaber
to uninstall (MAKE SURE YOU HAVE YOUR BACKUP APK AND DATA)adb install {patched apk path}
to install the patched version of Beat Saberadb push
to restore all the backup data files you made. You may need to launch the app once in its blank state for some of the directories to be created, then quit it and then restore.Removing songs is only possible using the advanced JSON interface described below. Right now if you want to remove songs just make a new COPY of your backup APK, patch that with all the songs you still want, and then install it.
If you get unhandled exceptions when trying to patch, maybe something like System.IO.InvalidDataException: End of Central Directory record could not be found.
, this might happen sometimes and I'm not sure why, the recently added transactions feature might have fixed it but I'm not sure. Anyhow restoring your patching APK by making a new copy from your IMPORTANT ORIGINAL BACKUP COPY and patching that should hopefully fix the errors.
If the resulting APK file the patcher produces is bigger than 2.1GB (the maximum size of a signed 32 bit integer) the APK will install but not launch. This puts a limit on how many songs you can add.
Because of the fact that this is a library, I could easily make a separate executable with an advanced JSON-based interface, intended for GUIs written in other languages to easily script.
You can check out the source in jsonApp2/Program.cs
, but the way it works is that you give it a JSON dictionary of command info as STDIN input and it executes the command and gives you back a single line JSON dictionary as output.
It works on a synchronization model and supports custom packs, you can just tell it what songs and packs you want, and it will patch the APK to make that the current state. It removes old songs and packs that are no longer needed. The resulting packs and levels should be the same for a given invocation regardless on whether you run it on an upatched APK or a previously patched one. It even removes old-school custom songs from the Extras collection so you can use it on an APK patched with an older version of QuestSaberPatch.
The input format is as follows, except being collapsed onto a single line is mandatory for real input:
{
"apkPath": "/Users/tristan/BeatSaber/base_testing.apk",
// If true, will patch the signature check in the code, this only needs to be done
// once per APK but you can have it always true at a slight performance cost
"patchSignatureCheck": true,
// If true, will sign the APK, after it does anything else
"sign": true,
// Each dictionary item is a levelID:levelFolder pair that will be installed if
// they aren't already present. The levelID can be any string you want but it must
// be globally unique across all songs you want to install, and all built in Beat
// Saber songs. See the output, which can return a list of installed levelIDs.
// All installed custom levels not present here will be removed.
//
// This is what controls what assets are put in the APK, if you put a level here
// and don't reference it from a pack, it will still be installed, it just won't be
// accessible.
"levels": {
"BUBBLETEA": "testdata/bubble_tea_song"
},
// This controls the level packs that will be displayed in the selector, in which
// order, how they show up and what songs are in them.
"packs": [
{
// Must be unique between packs but doesn't need to be consistent
"id": "CustomLevels1",
// Display name of the pack
"name": "Custom Levels",
// Image file for the cover that will be displayed for the pack
"coverImagePath": "testdata/bubble_tea_song/cover.jpg",
// List of level IDs in the pack in the order you want them displayed.
// Each levelID can be in multiple packs if you want.
"levelIDs": ["BUBBLETEA"],
}
],
// This attribute controls custom saber colors.
// If the entire "colors" attribute is missing or null, colors won't be updated
"colors": {
// A is the red/left hand by default, but left-handed people might use the setting to switch hands
"colorA": {"r": 0.941176, "g": 0.188235, "b": 0.75, "a": 1.0},
// null for either resets to the default color for that saber
"colorB": null,
},
// if null or missing, doesn't replace text, if non-null but even if an
// empty dictionary, adds usernames of all the Quest Modders to the credits
"replaceText": {
// See https://github.com/sc2ad/QuestModdingTools/blob/master/BeatSaberLocale.txt for
// what keys are available and what text they start with
"BUTTON_PLAY": "GO!",
}
}
And after running each command it will return an output JSON line that is in this format but all on one line:
{
// Just mirrors the patchSignatureCheck input
"didSignatureCheckPatch":true,
// Just mirrors the sign input
"didSign":true,
// All levelIDs present in the APK after the command finished
"presentLevels":["100Bills","AngelVoices","BalearicPumping","BeThereForYou","BeatSaber","Breezer","CommercialPumping","CountryRounds","CrabRave","Elixia","INeedYou","Legend","LvlInsane","OneHope","PopStars","RumNBass","TurnMeOn","UnlimitedPower","BUBBLETEA"],
// All the levelIDs successfully installed by the command
"installedLevels":["BUBBLETEA"],
// Custom levels that used to be in the APK but were removed
"removedLevels":["OldCustomSongLevelID"],
// Level IDs referenced in packs that weren't installed so couldn't be added.
// This could be because the levelID wasn't included in `levels` or because
// the level wasn't installed because it was invalid.
"missingFromPacks":[],
// All the levels in ensureInstalled that weren't installed and the reason. The
// reason is an error message about the level being invalid such as missing a
// file or the JSON being incorrect.
"installSkipped":{"BUBBLETEA":"Invalid level JSON: some error"},
// This will be null if `colors` was null. Otherwise it will contain the resulting
// saber colors. Below is the result on a fresh APK with both replacements null,
// i.e. these are the default colors in case they are useful for making a GUI
"newColors":{
"colorA":{"r":0.9411765,"g":0.1882353,"b":0.1882353,"a":1.0},
"colorB":{"r":0.1882353,"g":0.619607866,"b":1.0,"a":1.0}
},
// True if text replacement ran, mirrors `replaceText`
"didReplaceText":true,
// This should be null unless something screws up, in which case it will be a
// string with the exception message and backtrace, make sure to check this and
// preferably surface it somehow or at least log it.
"error":null
}
This interface should be present in builds past v0.6
as the jsonApp2
executable, and runnable from source with dotnet run -p jsonApp2/jsonApp2.csproj
.
If you want to use the new features to for example change your custom colors but don't want to wait for or use a GUI tool. You can manually edit a JSON file and then run the tool with it. Check out testdata/sample_invocation_v2.json
for a starter JSON file and you can run it like this (or with the jsonApp2
binary in a release):
dotnet run -p jsonApp2/jsonApp2.csproj < testdata/sample_invocation_v2.json
An older version of the advanced JSON interface is still available in the builds and source as jsonApp
. It's the one used by the original SideQuest integration and is kept around for compatibility.
You can read about its interface in an old version of the Readme. But I don't recommend using it now that the newer version is out.
Things I'm planning on doing but may or may not get around to include the following. I'd be open to accepting contributions or collaborating as long as you let me know what you want to work on so we don't collide. Not necessarily in order but approximately so: