├── .clang-format ├── .clangd ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── c_cpp_properties.json ├── settings.json └── tasks.json ├── CMakeLists.txt ├── Cover.jpg ├── README.md ├── default_config.json ├── include ├── Config.hpp ├── Main.hpp ├── Settings.hpp ├── TokenizedText.hpp └── json │ ├── Config.hpp │ └── DefaultConfig.hpp ├── mod.template.json ├── qpm.json ├── qpm.shared.json ├── scripts ├── build.ps1 ├── copy.ps1 ├── createqmod.ps1 ├── ndk-stack.ps1 ├── pull-tombstone.ps1 ├── restart-game.ps1 ├── start-logging.ps1 └── validate-modjson.ps1 └── src ├── Judgments.cpp ├── Main.cpp └── Settings.cpp /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: Google 4 | AccessModifierOffset: -1 5 | AlignAfterOpenBracket: BlockIndent 6 | AlignOperands: AlignAfterOperator 7 | AlignTrailingComments: 8 | Kind: Never 9 | AllowShortFunctionsOnASingleLine: Inline 10 | AllowShortIfStatementsOnASingleLine: Never 11 | AllowShortLambdasOnASingleLine: Inline 12 | AllowShortLoopsOnASingleLine: false 13 | BinPackArguments: false 14 | BinPackParameters: false 15 | BraceWrapping: 16 | SplitEmptyFunction: false 17 | SplitEmptyRecord: false 18 | SplitEmptyNamespace: false 19 | BreakAfterAttributes: Never 20 | BreakBeforeConceptDeclarations: Always 21 | BreakConstructorInitializers: AfterColon 22 | BreakInheritanceList: AfterColon 23 | BreakStringLiterals: false 24 | ColumnLimit: 150 25 | DerivePointerAlignment: false 26 | FixNamespaceComments: false 27 | IncludeBlocks: Regroup 28 | IncludeCategories: 29 | - Regex: '^' 30 | Priority: 2 31 | SortPriority: 0 32 | CaseSensitive: false 33 | - Regex: '^<.*\.h>' 34 | Priority: 1 35 | SortPriority: 0 36 | CaseSensitive: false 37 | - Regex: "^<.*" 38 | Priority: 2 39 | SortPriority: 0 40 | CaseSensitive: false 41 | - Regex: ".*" 42 | Priority: 3 43 | SortPriority: 0 44 | CaseSensitive: false 45 | IndentExternBlock: Indent 46 | IndentRequiresClause: false 47 | IndentWidth: 4 48 | InsertNewlineAtEOF: true 49 | KeepEmptyLinesAtTheStartOfBlocks: true 50 | LineEnding: LF 51 | NamespaceIndentation: All 52 | PackConstructorInitializers: CurrentLine 53 | PenaltyExcessCharacter: 100 54 | PenaltyReturnTypeOnItsOwnLine: 0 55 | QualifierAlignment: Right 56 | RequiresClausePosition: OwnLine 57 | RequiresExpressionIndentation: OuterScope 58 | SkipMacroDefinitionBody: true 59 | SpaceAfterCStyleCast: true 60 | SpacesBeforeTrailingComments: 2 61 | -------------------------------------------------------------------------------- /.clangd: -------------------------------------------------------------------------------- 1 | Diagnostics: 2 | UnusedIncludes: None 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | env: 4 | module_id: hitscorevisualizer 5 | qmodName: HitScoreVisualizer 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches-ignore: 11 | - 'version*' 12 | pull_request: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | name: Checkout 21 | with: 22 | submodules: true 23 | lfs: true 24 | 25 | - uses: seanmiddleditch/gha-setup-ninja@v3 26 | 27 | - name: Create ndkpath.txt 28 | run: | 29 | echo "$ANDROID_NDK_LATEST_HOME" > ${GITHUB_WORKSPACE}/ndkpath.txt 30 | cat ${GITHUB_WORKSPACE}/ndkpath.txt 31 | 32 | - name: QPM Action 33 | uses: Fernthedev/qpm-action@v1 34 | with: 35 | workflow_token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | restore: true 38 | cache: true 39 | 40 | - name: List Post Restore 41 | run: | 42 | echo includes: 43 | ls -la ${GITHUB_WORKSPACE}/extern/includes 44 | echo libs: 45 | ls -la ${GITHUB_WORKSPACE}/extern/libs 46 | 47 | - name: Build & create qmod 48 | run: | 49 | cd ${GITHUB_WORKSPACE} 50 | qpm s qmod 51 | 52 | - name: Get Library Name 53 | id: libname 54 | run: | 55 | cd ./build/ 56 | pattern="lib${module_id}*.so" 57 | files=( $pattern ) 58 | echo NAME="${files[0]}" >> $GITHUB_OUTPUT 59 | - name: Rename debug 60 | run: | 61 | mv ./build/debug/${{ steps.libname.outputs.NAME }} ./build/debug/debug_${{ steps.libname.outputs.NAME }} 62 | 63 | - name: Upload non-debug artifact 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: ${{ steps.libname.outputs.NAME }} 67 | path: ./build/${{ steps.libname.outputs.NAME }} 68 | if-no-files-found: error 69 | 70 | - name: Upload debug artifact 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: debug_${{ steps.libname.outputs.NAME }} 74 | path: ./build/debug/debug_${{ steps.libname.outputs.NAME }} 75 | if-no-files-found: error 76 | 77 | - name: Upload qmod artifact 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: ${{ env.qmodName }}.qmod 81 | path: ./${{ env.qmodName }}.qmod 82 | if-no-files-found: error 83 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | env: 4 | module_id: hitscorevisualizer 5 | qmodName: HitScoreVisualizer 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | version: 11 | description: 'Version to release (no v)' 12 | required: true 13 | release_msg: 14 | description: 'Message for release' 15 | required: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | name: Checkout 24 | with: 25 | submodules: true 26 | lfs: true 27 | 28 | - uses: seanmiddleditch/gha-setup-ninja@v3 29 | 30 | - name: Create ndkpath.txt 31 | run: | 32 | echo "$ANDROID_NDK_LATEST_HOME" > ${GITHUB_WORKSPACE}/ndkpath.txt 33 | cat ${GITHUB_WORKSPACE}/ndkpath.txt 34 | 35 | - name: QPM Action 36 | uses: Fernthedev/qpm-action@v1 37 | with: 38 | workflow_token: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | restore: true 41 | cache: true 42 | 43 | - name: List Post Restore 44 | run: | 45 | echo includes: 46 | ls -la ${GITHUB_WORKSPACE}/extern/includes 47 | echo libs: 48 | ls -la ${GITHUB_WORKSPACE}/extern/libs 49 | 50 | - name: Build & create qmod 51 | run: | 52 | cd ${GITHUB_WORKSPACE} 53 | qpm s qmod 54 | 55 | - name: Get Library Name 56 | id: libname 57 | run: | 58 | cd ./build/ 59 | pattern="lib${module_id}*.so" 60 | files=( $pattern ) 61 | echo NAME="${files[0]}" >> $GITHUB_OUTPUT 62 | 63 | - name: Rename debug 64 | run: | 65 | mv ./build/debug/${{ steps.libname.outputs.NAME }} ./build/debug/debug_${{ steps.libname.outputs.NAME }} 66 | 67 | - name: Upload to Release 68 | id: upload_file_release 69 | uses: softprops/action-gh-release@v0.1.12 70 | with: 71 | name: ${{ github.event.inputs.release_msg }} 72 | tag_name: v${{ github.event.inputs.version }} 73 | files: | 74 | ./build/${{ steps.libname.outputs.NAME }} 75 | ./build/debug/debug_${{ steps.libname.outputs.NAME }} 76 | ./${{ env.qmodName }}.qmod 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Compiled Object files 5 | *.slo 6 | *.lo 7 | *.o 8 | *.obj 9 | 10 | # Precompiled Headers 11 | *.gch 12 | *.pch 13 | 14 | # Compiled Dynamic libraries 15 | *.so 16 | *.dylib 17 | *.dll 18 | 19 | # Fortran module files 20 | *.mod 21 | *.smod 22 | 23 | # Compiled Static libraries 24 | *.lai 25 | *.la 26 | *.a 27 | *.lib 28 | 29 | # Executables 30 | *.exe 31 | *.out 32 | *.app 33 | 34 | # VSCode config stuff 35 | .cache 36 | !.vscode/c_cpp_properties.json 37 | !.vscode/tasks.json 38 | !.vscode/settings.json 39 | 40 | # Jetbrains IDEs 41 | .idea/ 42 | 43 | # NDK stuff 44 | out/ 45 | [Ll]ib/ 46 | [Ll]ibs/ 47 | [Oo]bj/ 48 | [Oo]bjs/ 49 | ndkpath.txt 50 | *.zip 51 | *.txt 52 | *.log 53 | Android.mk.backup 54 | 55 | # QPM stuff 56 | [Ee]xtern/ 57 | *.qmod 58 | mod.json 59 | qpm_defines.cmake 60 | ![Cc][Mm]ake[Ll]ists.txt 61 | 62 | # CMake stuff 63 | [Bb]uild/ 64 | cmake-build-*/ 65 | extern.cmake 66 | 67 | # QMOD Schema 68 | mod.json.schema 69 | -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "defines": [ 5 | "__GNUC__", 6 | "__aarch64__", 7 | "VERSION=\"0.0.1\"", 8 | "ID=\"playlistmanager\"" 9 | ], 10 | "includePath": [ 11 | "${workspaceFolder}/**", 12 | "${workspaceFolder}/include/**", 13 | "${workspaceFolder}/shared/**", 14 | "${workspaceFolder}/extern/**", 15 | "${workspaceFolder}/extern/codegen/include/**" 16 | ], 17 | "name": "Quest", 18 | "cStandard": "c11", 19 | "cppStandard": "c++20", 20 | "intelliSenseMode": "clang-x64", 21 | "compileCommands": "${workspaceFolder}/build/compile_commands.json" 22 | } 23 | ], 24 | "version": 4 25 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "iosfwd": "cpp", 4 | "__config": "cpp", 5 | "__nullptr": "cpp", 6 | "thread": "cpp", 7 | "any": "cpp", 8 | "deque": "cpp", 9 | "list": "cpp", 10 | "map": "cpp", 11 | "optional": "cpp", 12 | "queue": "cpp", 13 | "set": "cpp", 14 | "stack": "cpp", 15 | "unordered_map": "cpp", 16 | "unordered_set": "cpp", 17 | "variant": "cpp", 18 | "vector": "cpp", 19 | "__bit_reference": "cpp", 20 | "__debug": "cpp", 21 | "__errc": "cpp", 22 | "__functional_base": "cpp", 23 | "__hash_table": "cpp", 24 | "__locale": "cpp", 25 | "__mutex_base": "cpp", 26 | "__node_handle": "cpp", 27 | "__split_buffer": "cpp", 28 | "__string": "cpp", 29 | "__threading_support": "cpp", 30 | "__tree": "cpp", 31 | "__tuple": "cpp", 32 | "algorithm": "cpp", 33 | "array": "cpp", 34 | "atomic": "cpp", 35 | "bit": "cpp", 36 | "bitset": "cpp", 37 | "cctype": "cpp", 38 | "cfenv": "cpp", 39 | "charconv": "cpp", 40 | "chrono": "cpp", 41 | "cinttypes": "cpp", 42 | "clocale": "cpp", 43 | "cmath": "cpp", 44 | "codecvt": "cpp", 45 | "compare": "cpp", 46 | "complex": "cpp", 47 | "condition_variable": "cpp", 48 | "csetjmp": "cpp", 49 | "csignal": "cpp", 50 | "cstdarg": "cpp", 51 | "cstddef": "cpp", 52 | "cstdint": "cpp", 53 | "cstdio": "cpp", 54 | "cstdlib": "cpp", 55 | "cstring": "cpp", 56 | "ctime": "cpp", 57 | "cwchar": "cpp", 58 | "cwctype": "cpp", 59 | "exception": "cpp", 60 | "coroutine": "cpp", 61 | "propagate_const": "cpp", 62 | "forward_list": "cpp", 63 | "fstream": "cpp", 64 | "functional": "cpp", 65 | "future": "cpp", 66 | "initializer_list": "cpp", 67 | "iomanip": "cpp", 68 | "ios": "cpp", 69 | "iostream": "cpp", 70 | "istream": "cpp", 71 | "iterator": "cpp", 72 | "limits": "cpp", 73 | "locale": "cpp", 74 | "memory": "cpp", 75 | "mutex": "cpp", 76 | "new": "cpp", 77 | "numeric": "cpp", 78 | "ostream": "cpp", 79 | "random": "cpp", 80 | "ratio": "cpp", 81 | "regex": "cpp", 82 | "scoped_allocator": "cpp", 83 | "span": "cpp", 84 | "sstream": "cpp", 85 | "stdexcept": "cpp", 86 | "streambuf": "cpp", 87 | "string": "cpp", 88 | "string_view": "cpp", 89 | "strstream": "cpp", 90 | "system_error": "cpp", 91 | "tuple": "cpp", 92 | "type_traits": "cpp", 93 | "typeindex": "cpp", 94 | "typeinfo": "cpp", 95 | "utility": "cpp", 96 | "valarray": "cpp", 97 | "xstring": "cpp", 98 | "xlocale": "cpp", 99 | "xlocbuf": "cpp", 100 | "concepts": "cpp", 101 | "filesystem": "cpp", 102 | "shared_mutex": "cpp", 103 | "xfacet": "cpp", 104 | "xhash": "cpp", 105 | "xiosbase": "cpp", 106 | "xlocinfo": "cpp", 107 | "xlocmes": "cpp", 108 | "xlocmon": "cpp", 109 | "xlocnum": "cpp", 110 | "xloctime": "cpp", 111 | "xmemory": "cpp", 112 | "xstddef": "cpp", 113 | "xtr1common": "cpp", 114 | "xtree": "cpp", 115 | "xutility": "cpp", 116 | "__functional_base_03": "cpp" 117 | } 118 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "NDK Build", 6 | "detail": "Builds the library using ndk-build.cmd", 7 | "type": "shell", 8 | "command": "ndk-build", 9 | "windows": { 10 | "command": "ndk-build.cmd" 11 | }, 12 | "args": ["NDK_PROJECT_PATH=.", "APP_BUILD_SCRIPT=./Android.mk", "NDK_APPLICATION_MK=./Application.mk"], 13 | "group": "build", 14 | "options": { 15 | "env": {} 16 | } 17 | }, 18 | { 19 | "label": "Powershell Build", 20 | "detail": "Builds the library using Powershell (recommended)", 21 | "type": "shell", 22 | "command": "./build.ps1", 23 | "windows": { 24 | "command": "./build.ps1" 25 | }, 26 | "group": { 27 | "kind": "build", 28 | "isDefault": true 29 | }, 30 | "options": { 31 | "env": {} 32 | } 33 | }, 34 | { 35 | "label": "Powershell Build and Copy", 36 | "detail": "Builds and copies the library to the Quest using ADB and force-quits Beat Saber", 37 | "type": "shell", 38 | "command": "./copy.ps1", 39 | "windows": { 40 | "command": "./copy.ps1" 41 | }, 42 | "group": "build", 43 | "options": { 44 | "env": {} 45 | } 46 | }, 47 | { 48 | "label": "QMOD Build", 49 | "detail": "Builds a .qmod to be installed into BMBF or QuestPatcher", 50 | "type": "shell", 51 | "command": "./buildQMOD.ps1", 52 | "windows": { 53 | "command": "./buildQMOD.ps1" 54 | }, 55 | "args": [], 56 | "group": "build", 57 | "options": { 58 | "env": {} 59 | } 60 | }, 61 | { 62 | "label": "Start logging", 63 | "detail": "Begin logging from the Quest to the console", 64 | "type": "shell", 65 | "command": "./start-logging.ps1", 66 | "windows": { 67 | "command": "./start-logging.ps1" 68 | } 69 | }, 70 | { 71 | "label": "Start logging to file", 72 | "detail": "Begin logging from the Quest to the console and saving output to a file 'logcat.log'", 73 | "type": "shell", 74 | "command": "./start-logging.ps1 --file", 75 | "windows": { 76 | "command": "./start-logging.ps1 --file" 77 | } 78 | }, 79 | { 80 | "label": "Restart Beat Saber", 81 | "detail": "Force-quits and restarts Beat Saber on the Quest", 82 | "type": "shell", 83 | "command": "./start-logging.ps1", 84 | "windows": { 85 | "command": "./start-logging.ps1" 86 | } 87 | } 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # include some defines automatically made by qpm 2 | include(qpm_defines.cmake) 3 | 4 | # Enable link time optimization 5 | # In my experience, this can be highly unstable but it nets a huge size optimization and likely performance 6 | # However, the instability was seen using Android.mk/ndk-build builds. With Ninja + CMake, this problem seems to have been solved. 7 | set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) 8 | 9 | cmake_minimum_required(VERSION 3.21) 10 | project(${COMPILE_ID}) 11 | 12 | # export compile commands for significantly better intellisense 13 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 14 | 15 | # c++ standard 16 | set(CMAKE_CXX_STANDARD 20) 17 | set(CMAKE_CXX_STANDARD_REQUIRED 20) 18 | 19 | # define that stores the actual source directory 20 | set(SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) 21 | set(INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include) 22 | 23 | # compile options used 24 | add_compile_options(-frtti -fexceptions) 25 | add_compile_options(-O3) 26 | 27 | # compile definitions used 28 | add_compile_definitions(VERSION=\"${MOD_VERSION}\") 29 | add_compile_definitions(MOD_ID=\"${MOD_ID}\") 30 | 31 | string(LENGTH "${CMAKE_CURRENT_LIST_DIR}/" FOLDER_LENGTH) 32 | add_compile_definitions("PAPER_ROOT_FOLDER_LENGTH=${FOLDER_LENGTH}") 33 | 34 | # recursively get all src files 35 | recurse_files(cpp_file_list ${SOURCE_DIR}/*.cpp) 36 | recurse_files(c_file_list ${SOURCE_DIR}/*.c) 37 | 38 | recurse_files(inline_hook_c ${EXTERN_DIR}/includes/beatsaber-hook/shared/inline-hook/*.c) 39 | recurse_files(inline_hook_cpp ${EXTERN_DIR}/includes/beatsaber-hook/shared/inline-hook/*.cpp) 40 | 41 | # add all src files to compile 42 | add_library( 43 | ${COMPILE_ID} SHARED ${cpp_file_list} ${c_file_list} ${inline_hook_c} ${inline_hook_cpp} 44 | ) 45 | 46 | # add include dir as include dir 47 | target_include_directories(${COMPILE_ID} PRIVATE ${INCLUDE_DIR}) 48 | 49 | target_link_libraries(${COMPILE_ID} PRIVATE -llog) 50 | 51 | # add extern stuff like libs and other includes 52 | include(extern.cmake) 53 | 54 | add_custom_command( 55 | TARGET ${COMPILE_ID} 56 | POST_BUILD 57 | COMMAND ${CMAKE_STRIP} -d --strip-all "lib${COMPILE_ID}.so" -o "stripped_lib${COMPILE_ID}.so" 58 | COMMENT "Strip debug symbols done on final binary." 59 | ) 60 | 61 | add_custom_command( 62 | TARGET ${COMPILE_ID} 63 | POST_BUILD 64 | COMMAND ${CMAKE_COMMAND} -E make_directory debug 65 | COMMENT "Make directory for debug symbols" 66 | ) 67 | 68 | add_custom_command( 69 | TARGET ${COMPILE_ID} 70 | POST_BUILD 71 | COMMAND ${CMAKE_COMMAND} -E rename lib${COMPILE_ID}.so debug/lib${COMPILE_ID}.so 72 | COMMENT "Rename the lib to debug_ since it has debug symbols" 73 | ) 74 | 75 | # strip debug symbols from the .so and all dependencies 76 | add_custom_command( 77 | TARGET ${COMPILE_ID} 78 | POST_BUILD 79 | COMMAND ${CMAKE_COMMAND} -E rename stripped_lib${COMPILE_ID}.so lib${COMPILE_ID}.so 80 | COMMENT "Rename the stripped lib to regular" 81 | ) 82 | -------------------------------------------------------------------------------- /Cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christopherweinhardt/HitScoreVisualizer/8ad279fa3aa5e74661fde1fb5a6fea3616ce47f5/Cover.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HitScoreVisualizer 2 | 3 | A fairly simple mod that allows you to heavily customize the appearance of hit scores. 4 | 5 | ## Usage 6 | 7 | When you first run the game after installing this mod, it will create a new folder at `/sdcard/ModData/com.beatgames.beatsaber/Mods/HitScoreVisualizer`. In that folder, you can drop all your HSV config files. It doesn't even matter if you create new folders in that folder, HSV will still be able to find them all. 8 | 9 | > [!TIP] 10 | > While you can have config files with the same name when using folder structures, **I still strongly advise you to use unique config filenames** because, despite HSV being able to handle them just fine, you might end up with several files in the list that appear identical. 11 | 12 | After placing your configs in the folder, you can open the settings ingame and select one. 13 | 14 | ## Creating a Custom Config 15 | 16 | There are various places that configs can be sourced, and there's nothing wrong with using the default config or copying someone else's file and not worrying about it. However, if you want to use the full customizability of the mod, this is how to create and modify a config file. 17 | 18 | > [!NOTE] 19 | > The default config, used when you have no others selected, is no longer an actual file created by the mod due to issues with people not setting `isDefaultConfig` to `false` when sharing their configs. It can still be referenced as a starting point, and can be found in the [default_config.json](default_config.json) in this repository. 20 | 21 | Following is a list of all the properties in the config used by the current version of the mod. Extra values may be found in older configs or those sourced from the PC version, but any property not in this table will simply be ignored. 22 | 23 | | Property name(s) | Explanation / Info | Example or possible values | 24 | | --- | --- | --- | 25 | | `fixedPosition` | Optional coordinates of a fixed location where the hit scores will be shown.
Either leave this out or set as `null` to use normal hit score positions and animation. | | 26 | | `targetPositionOffset` | An optional vector that offsets the destination location of every hit score.
If a fixed position is defined, it will take priority and this will be ignored. | | 27 | | `judgments` | A list of Judgments used to customize the general Judgments for non-chain hit scores. | Uses Judgment objects.
More info below. | 28 | | `beforeCutAngleJudgments` | A list used to customize the Judgments for the part of the swing before cutting the block (0 - 70).
Format token: `%B` | Uses JudgmentSegments objects.
More info below. | 29 | | `afterCutAngleJudgments` | A list used to customize the Judgments for the part of the swing after cutting the block (0 - 30).
Format token: `%A`
| Uses JudgmentSegments.
More info below. | 30 | | `accuracyJudgments` | A list used to customize the Judgments for the accuracy of the cut (0 - 15).
Format token: `%C`
| Uses JudgmentSegments objects.
More info below. | 31 | | `timeDependencyJudgments` | A list used to customize the Judgments for the time dependence (0 - 1).
Format token: `%T`
| Uses TimeDependenceJudgmentSegments.
More info below. | 32 | | `chainHeadJudgments` | A list used to customize the Judgments for chain head hit scores. | Uses Judgment objects. More info below. | 33 | | `chainLinkDisplay` | A Judgment that will always display when hitting the links of the chain block.
Some fields and tokens don't do anything here because the links are always the same score. | Uses Judgment objects. More info below. | 34 | | `timeDependencyDecimalPrecision` | The number of decimal places used when time dependence is shown.
**Must be between 0 and 99, inclusive** | | 35 | | `timeDependencyDecimalOffset` | A power of 10 that the displayed time dependence will be multiplied by.
Time dependence is from 0 - 1, so this will allow it to be shown between 0 and 10, 0 and 100, etc.
**Must be between 0 and 38, inclusive** | | 36 | | `badCutDisplays` | A list of BadCutDisplays that can change the regular bad cut text. | Uses BadCutDisplay objects. More info below. | 37 | | `randomizeBadCutDisplays` | If true, a random item from the `badCutDisplays` list will be shown for every bad cut. Otherwise, it will go through them in order. | `true` | 38 | | `missDisplays` | A list of MissDisplays that can change the regular miss text. | Uses MissDisplay objects. More info below. | 39 | | `randomizeMissDisplays` | If true, a random item from the `missDisplays` list will be shown for every miss. Otherwise, it will go through them in order. | `true` | 40 | 41 | ### Important info 42 | 43 | - The `text` property of Judgment, JudgmentSegment, TimeDependenceJudgmentSegment, BadCutDisplay, and MissDisplay objects all have support for [TextMeshPro formatting](http://digitalnativestudios.com/textmeshpro/docs/rich-text/). 44 | - The order of Judgments and JudgmentSegments in their lists does not matter, unless none of the Judgments fit the threshold for a hit score, in which case the last one will be used. 45 | - `chainHeadJudgments` and `chainLinkDisplay` are not required in configs for backwards compatibility, and configs without them will simply not affect the displayed scores for those types of notes. 46 | - Bad cut and miss related fields are also not required in configs. 47 | 48 | ### Format tokens 49 | 50 | | Token | Explanation / Info | 51 | | --- | --- | 52 | | `%b` | The score contributed by the swing before cutting the block. | 53 | | `%a` | The score contributed by the part of the swing after cutting the block. | 54 | | `%c` | The score contributed by the accuracy of the cut. | 55 | | `%t` | The time dependence of the swing. This value indicates how depedent the accuracy part of the score is upon *when* you hit the block, measured from 0 - 1. A value of 0 indicates a completely time independent swing, while a value of 1 indicates that the accuracy part of the score would vary greatly if the block was hit even slightly earlier or later. 56 | | `%B`, `%C`, `%A`, and `%T` | Uses the Judgment text that matches the threshold as specified in either `beforeCutAngleJudgments`, `accuracyJudgments`, `afterCutAngleJudgments`, or `timeDependencyJudgments` (depending on the used token). | 57 | | `%s` | The total score of the cut. | 58 | | `%p` | The percentage of the total score of the cut out of the maximum possible. | 59 | | `%%` | A literal percent symbol. | 60 | | `%n` | A newline. | 61 | 62 | ### Judgment objects 63 | 64 | | Property name(s) | Explanation / Info | Example or possible values | 65 | | --- | --- | --- | 66 | | `threshold` | The threshold that defines if this Judgment will be used for a given score. The Judgment will be used if it is the one with the highest threshold that's either equal or smaller than the given hit score.
It can also be omitted when it's the Judgment for the lowest scores. | `110` | 67 | | `text` | The text to display. This can contain the formatting tokens seen above, and they will be replaced with their corresponding values or segments. | `"%BFantastic%A%n%s"` | 68 | | `color` | An array that specifies the color. Consists of 4 floating numbers ranging between (inclusive) 0 and 1, corresponding to Red, Green, Blue, and Alpha. | `[0, 0.5, 1, 0.75]` | 69 | | `fade` | If true, the text color will be interpolated between this Judgment's color and the Judgment with the next highest threshold based on how close to the next threshold it is. | `false` | 70 | 71 | ### JudgmentSegment objects 72 | 73 | | Property name(s) | Explanation / Info | Example or possible values | 74 | | --- | --- | --- | 75 | | threshold | The threshold that defines if this segment will be used for a given score. The segment will be used if it is the one with the highest threshold that's either equal or smaller than the given part of the hit score.
It can also be omitted for the segment for the lowest scores. | `30` | 76 | | text | The text to display. The above format tokens will not be replaced in this text. | `"+++"` | 77 | 78 | ### TimeDependenceJudgmentSegment objects 79 | 80 | | Property name(s) | Explanation / Info | Example or possible values | 81 | | --- | --- | --- | 82 | | threshold | The threshold that defines if this segment will be used for a given time dependence. The segment will be used if it is the one with the highest threshold that's either equal or smaller than the time dependence.
It can also be omitted for the segment for the lowest time dependences. | 0.5 | 83 | | text | The text to display. The above format tokens will not be replaced in this text. | "+++" | 84 | 85 | ### BadCutDisplay objects 86 | 87 | | Property name(s) | Explanation / Info | Example or possible values | 88 | | --- | --- | --- | 89 | | text | The text to display. No format tokens will be replaced. | `"Oops"` | 90 | | type | The type of bad cuts this text can be shown for. If omitted, it can be shown for any bad cut. | | 91 | | `color` | An array that specifies the color. Consists of 4 floating numbers ranging between (inclusive) 0 and 1, corresponding to Red, Green, Blue, and Alpha. | `[0, 0.5, 1, 0.75]` | 92 | 93 | ### MissDisplay objects 94 | 95 | | Property name(s) | Explanation / Info | Example or possible values | 96 | | --- | --- | --- | 97 | | text | The text to display. No format tokens will be replaced. | `"Oops 2"` | 98 | | `color` | An array that specifies the color. Consists of 4 floating numbers ranging between (inclusive) 0 and 1, corresponding to Red, Green, Blue, and Alpha. | `[0, 0.5, 1, 0.75]` | 99 | 100 | ## Useful links 101 | 102 | [HSV Config Creator by MoreOwO](https://github.com/MoreOwO/HSV-Config-Creator/releases/latest): A program that helps you create configs for HSV. (May not always be up-to-date with the latest features.) 103 | 104 | ## Credits 105 | 106 | - [artemiswkearney](https://github.com/artemiswkearney), [ErisApps](https://github.com/ErisApps), and [qqrz](https://github.com/qqrz997) for the PC mod 107 | - [Metalit](https://github.com/Metalit) for maintaining the quest mod now 108 | -------------------------------------------------------------------------------- /default_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "judgments": [ 3 | { 4 | "text": "%s", 5 | "color": [1, 1, 1, 1], 6 | "threshold": 115, 7 | "fade": false 8 | }, 9 | { 10 | "text": "%B%C%s%A", 11 | "color": [0, 0.5, 1, 1], 12 | "threshold": 110, 13 | "fade": false 14 | }, 15 | { 16 | "text": "%B%C%s%A", 17 | "color": [0, 1, 0, 1], 18 | "threshold": 105, 19 | "fade": false 20 | }, 21 | { 22 | "text": "%B%C%s%A", 23 | "color": [1, 1, 0, 1], 24 | "threshold": 100, 25 | "fade": false 26 | }, 27 | { 28 | "text": "%B%s%A", 29 | "color": [1, 0, 0, 1], 30 | "threshold": 50, 31 | "fade": true 32 | }, 33 | { 34 | "text": "%B%s%A", 35 | "color": [1, 0, 0, 1], 36 | "threshold": 0, 37 | "fade": false 38 | } 39 | ], 40 | "chainHeadJudgments": [ 41 | { 42 | "text": "%s", 43 | "color": [1, 1, 1, 1], 44 | "threshold": 85, 45 | "fade": false 46 | }, 47 | { 48 | "text": "%B%C%s%A", 49 | "color": [0, 0.5, 1, 1], 50 | "threshold": 80, 51 | "fade": false 52 | }, 53 | { 54 | "text": "%B%C%s%A", 55 | "color": [0, 1, 0, 1], 56 | "threshold": 75, 57 | "fade": false 58 | }, 59 | { 60 | "text": "%B%C%s%A", 61 | "color": [1, 1, 0, 1], 62 | "threshold": 70, 63 | "fade": false 64 | }, 65 | { 66 | "text": "%B%s%A", 67 | "color": [1, 0, 0, 1], 68 | "threshold": 35, 69 | "fade": true 70 | }, 71 | { 72 | "text": "%B%s%A", 73 | "color": [1, 0, 0, 1], 74 | "threshold": 0, 75 | "fade": false 76 | } 77 | ], 78 | "chainLinkDisplay": { 79 | "text": "%s", 80 | "color": [1, 1, 1, 1] 81 | }, 82 | "beforeCutAngleJudgments": [ 83 | { 84 | "threshold": 70, 85 | "text": " + " 86 | }, 87 | { 88 | "threshold": 0, 89 | "text": " - " 90 | } 91 | ], 92 | "accuracyJudgments": [ 93 | { 94 | "threshold": 15, 95 | "text": "" 96 | }, 97 | { 98 | "threshold": 0, 99 | "text": "" 100 | } 101 | ], 102 | "afterCutAngleJudgments": [ 103 | { 104 | "threshold": 30, 105 | "text": " + " 106 | }, 107 | { 108 | "threshold": 0, 109 | "text": " - " 110 | } 111 | ], 112 | "timeDependencyJudgments": [], 113 | "timeDependencyDecimalPrecision": 1, 114 | "timeDependencyDecimalOffset": 2 115 | } 116 | -------------------------------------------------------------------------------- /include/Config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "json/DefaultConfig.hpp" 4 | 5 | DECLARE_CONFIG(GlobalConfig) { 6 | CONFIG_VALUE(ModEnabled, bool, "isEnabled", true); 7 | CONFIG_VALUE(SelectedConfig, std::string, "selectedConfig", ""); 8 | CONFIG_VALUE(HideUntilDone, bool, "hideUntilCalculated", false); 9 | // not actually written to the config file 10 | HSV::Config CurrentConfig = defaultConfig; 11 | }; 12 | -------------------------------------------------------------------------------- /include/Main.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "GlobalNamespace/CutScoreBuffer.hpp" 4 | #include "GlobalNamespace/FlyingScoreEffect.hpp" 5 | #include "GlobalNamespace/FlyingTextSpawner.hpp" 6 | #include "GlobalNamespace/NoteController.hpp" 7 | #include "GlobalNamespace/NoteCutInfo.hpp" 8 | #include "beatsaber-hook/shared/utils/logging.hpp" 9 | 10 | constexpr auto logger = Paper::ConstLoggerContext(MOD_ID); 11 | 12 | std::string ConfigsPath(); 13 | 14 | void LoadCurrentConfig(); 15 | void Judge( 16 | GlobalNamespace::CutScoreBuffer* cutScoreBuffer, 17 | GlobalNamespace::FlyingScoreEffect* flyingScoreEffect, 18 | GlobalNamespace::NoteCutInfo const& noteCutInfo 19 | ); 20 | bool SpawnBadCut(GlobalNamespace::FlyingTextSpawner* spawner, GlobalNamespace::NoteCutInfo const& noteCutInfo); 21 | bool SpawnMiss(GlobalNamespace::FlyingTextSpawner* spawner, GlobalNamespace::NoteController* note, float z); 22 | -------------------------------------------------------------------------------- /include/Settings.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "GlobalNamespace/SimpleTextTableCell.hpp" 6 | #include "HMUI/TableCell.hpp" 7 | #include "HMUI/TableView.hpp" 8 | #include "HMUI/ViewController.hpp" 9 | #include "TMPro/TextMeshProUGUI.hpp" 10 | #include "bsml/shared/BSML/Components/Settings/ToggleSetting.hpp" 11 | #include "custom-types/shared/macros.hpp" 12 | 13 | DECLARE_CLASS_CODEGEN_INTERFACES(HSV, CustomList, UnityEngine::MonoBehaviour, HMUI::TableView::IDataSource*) { 14 | DECLARE_INSTANCE_FIELD(GlobalNamespace::SimpleTextTableCell*, simpleTextTableCellInstance); 15 | 16 | DECLARE_INSTANCE_FIELD(StringW, reuseIdentifier); 17 | DECLARE_INSTANCE_FIELD(float, cellSize); 18 | DECLARE_INSTANCE_FIELD(HMUI::TableView*, tableView); 19 | DECLARE_INSTANCE_FIELD(bool, expandCell); 20 | 21 | DECLARE_CTOR(ctor); 22 | 23 | DECLARE_OVERRIDE_METHOD_MATCH(HMUI::TableCell*, CellForIdx, &HMUI::TableView::IDataSource::CellForIdx, HMUI::TableView * tableView, int idx); 24 | DECLARE_OVERRIDE_METHOD_MATCH(float, CellSize, &HMUI::TableView::IDataSource::CellSize); 25 | DECLARE_OVERRIDE_METHOD_MATCH(int, NumberOfCells, &HMUI::TableView::IDataSource::NumberOfCells); 26 | 27 | public: 28 | std::vector data; 29 | std::map failures; 30 | }; 31 | 32 | DECLARE_CLASS_CODEGEN(HSV, SettingsViewController, HMUI::ViewController) { 33 | DECLARE_DEFAULT_CTOR(); 34 | 35 | DECLARE_INSTANCE_FIELD(BSML::ToggleSetting*, enabledToggle); 36 | DECLARE_INSTANCE_FIELD(BSML::ToggleSetting*, hideToggle); 37 | DECLARE_INSTANCE_FIELD(TMPro::TextMeshProUGUI*, selectedConfig); 38 | DECLARE_INSTANCE_FIELD(HSV::CustomList*, configList); 39 | 40 | DECLARE_INSTANCE_METHOD(void, ConfigSelected, int idx); 41 | DECLARE_INSTANCE_METHOD(void, RefreshConfigList); 42 | DECLARE_INSTANCE_METHOD(void, RefreshUI); 43 | 44 | DECLARE_OVERRIDE_METHOD_MATCH( 45 | void, DidActivate, &HMUI::ViewController::DidActivate, bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling 46 | ); 47 | 48 | private: 49 | static std::vector fullConfigPaths; 50 | static int selectedIdx; 51 | }; 52 | -------------------------------------------------------------------------------- /include/TokenizedText.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | // Create a setNAME method that reads from the name##Tokens field and sets all tokens listed. 10 | #define SET_TOKEN(name) \ 11 | void set_##name(std::string_view name) { \ 12 | invalidate_text(); \ 13 | for (int idx : name##Tokens) \ 14 | tokens[idx] = name; \ 15 | } \ 16 | 17 | class TokenizedText { 18 | public: 19 | TokenizedText() = default; 20 | bool operator==(TokenizedText const&) const = default; 21 | 22 | TokenizedText(std::string str) { 23 | original = str; 24 | // Parse the string into tokens, converting the string back is easy. 25 | std::stringstream nonPercentStr; 26 | int i = 0; 27 | bool isPercent = false; 28 | for (char const current : str) { 29 | if (isPercent) { 30 | std::string buffer; 31 | if (current == 'n') 32 | tokens.emplace_back("\n"); 33 | else if (current == '%') 34 | tokens.emplace_back("%"); 35 | else { 36 | switch (current) { 37 | case 'b': 38 | beforeCutTokens.push_back(i); 39 | tokens.emplace_back(""); 40 | break; 41 | case 'c': 42 | accuracyTokens.push_back(i); 43 | tokens.emplace_back(""); 44 | break; 45 | case 'a': 46 | afterCutTokens.push_back(i); 47 | tokens.emplace_back(""); 48 | break; 49 | case 't': 50 | timeDependencyTokens.push_back(i); 51 | tokens.emplace_back(""); 52 | break; 53 | case 'B': 54 | beforeCutSegmentTokens.push_back(i); 55 | tokens.emplace_back(""); 56 | break; 57 | case 'C': 58 | accuracySegmentTokens.push_back(i); 59 | tokens.emplace_back(""); 60 | break; 61 | case 'A': 62 | afterCutSegmentTokens.push_back(i); 63 | tokens.emplace_back(""); 64 | break; 65 | case 'T': 66 | timeDependencySegmentTokens.push_back(i); 67 | tokens.emplace_back(""); 68 | break; 69 | case 's': 70 | scoreTokens.push_back(i); 71 | tokens.emplace_back(""); 72 | break; 73 | case 'p': 74 | percentTokens.push_back(i); 75 | tokens.emplace_back(""); 76 | break; 77 | case 'd': 78 | directionTokens.push_back(i); 79 | tokens.emplace_back(""); 80 | break; 81 | default: 82 | // keep % when it doesn't correspond to a key 83 | auto str = std::string("%") + current; 84 | tokens.push_back(str); 85 | isPercent = false; 86 | i++; 87 | continue; 88 | } 89 | } 90 | isPercent = false; 91 | i++; 92 | continue; 93 | } else if (current == '%') { 94 | tokens.emplace_back(nonPercentStr.str()); 95 | nonPercentStr.str(std::string()); 96 | isPercent = true; 97 | i++; 98 | } else 99 | nonPercentStr.put(current); 100 | } 101 | if (nonPercentStr.str().size() != 0) 102 | tokens.emplace_back(nonPercentStr.str()); 103 | } 104 | 105 | std::string Raw() { return original; } 106 | // Get the token-joined string from creation of this 107 | std::string Join() { 108 | if (!textValid) { 109 | textValid = true; 110 | text = std::string(); 111 | for (auto const& piece : tokens) 112 | text += piece; 113 | } 114 | return text; 115 | } 116 | 117 | void invalidate_text() { textValid = false; } 118 | 119 | bool is_text_valid() { return textValid; } 120 | 121 | SET_TOKEN(beforeCut) 122 | SET_TOKEN(accuracy) 123 | SET_TOKEN(afterCut) 124 | SET_TOKEN(timeDependency) 125 | SET_TOKEN(percent) 126 | SET_TOKEN(beforeCutSegment) 127 | SET_TOKEN(accuracySegment) 128 | SET_TOKEN(afterCutSegment) 129 | SET_TOKEN(timeDependencySegment) 130 | SET_TOKEN(score) 131 | SET_TOKEN(direction) 132 | 133 | std::string original; 134 | std::vector tokens; 135 | 136 | private: 137 | // Is cached text valid? Should be invalidated on tokens change 138 | bool textValid = false; 139 | // Cached text, should be invalidated on tokens change 140 | std::string text; 141 | 142 | std::vector beforeCutTokens; 143 | std::vector accuracyTokens; 144 | std::vector afterCutTokens; 145 | std::vector timeDependencyTokens; 146 | std::vector percentTokens; 147 | std::vector beforeCutSegmentTokens; 148 | std::vector accuracySegmentTokens; 149 | std::vector afterCutSegmentTokens; 150 | std::vector timeDependencySegmentTokens; 151 | std::vector scoreTokens; 152 | std::vector directionTokens; 153 | }; 154 | 155 | #undef SET_TOKEN 156 | -------------------------------------------------------------------------------- /include/json/Config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "TokenizedText.hpp" 4 | #include "UnityEngine/Color.hpp" 5 | #include "UnityEngine/Vector3.hpp" 6 | #include "config-utils/shared/config-utils.hpp" 7 | 8 | namespace HSV { 9 | inline std::vector const BadCutTypes = {"All", "WrongDirection", "WrongColor", "Bomb"}; 10 | 11 | DECLARE_JSON_STRUCT(ColorArray) { 12 | NAMED_VECTOR(float, RawColor, SELF_OBJECT_NAME); 13 | DESERIALIZE_FUNCTION(ParseColor) { 14 | if (RawColor.size() != 4) 15 | throw JSONException("invalid color array length"); 16 | Color = {RawColor[0], RawColor[1], RawColor[2], RawColor[3]}; 17 | }; 18 | UnityEngine::Color Color; 19 | 20 | ColorArray(UnityEngine::Color color) : RawColor{color.r, color.g, color.b, color.a}, Color(color) {} 21 | ColorArray() = default; 22 | }; 23 | 24 | DECLARE_JSON_STRUCT(Judgement) { 25 | private: 26 | NAMED_VALUE(std::string, UnprocessedText, "text"); 27 | DESERIALIZE_FUNCTION(ParseText) { 28 | Text = TokenizedText(UnprocessedText); 29 | }; 30 | 31 | public: 32 | NAMED_VALUE(ColorArray, Color, "color"); 33 | NAMED_VALUE_DEFAULT(int, Threshold, 0, "threshold"); 34 | NAMED_VALUE_OPTIONAL(bool, Fade, "fade"); 35 | TokenizedText Text; 36 | 37 | Judgement(int threshold, std::string text, UnityEngine::Color color, bool fade = false) : 38 | Threshold(threshold), 39 | Text(text), 40 | Color(color), 41 | Fade(fade) {} 42 | Judgement() = default; 43 | }; 44 | 45 | DECLARE_JSON_STRUCT(Segment) { 46 | NAMED_VALUE_DEFAULT(int, Threshold, 0, "threshold"); 47 | NAMED_VALUE(std::string, Text, "text"); 48 | 49 | Segment(int threshold, std::string text) : Threshold(threshold), Text(text) {} 50 | Segment() = default; 51 | }; 52 | 53 | DECLARE_JSON_STRUCT(FloatSegment) { 54 | NAMED_VALUE_DEFAULT(float, Threshold, 0, "threshold"); 55 | NAMED_VALUE(std::string, Text, "text"); 56 | 57 | FloatSegment(int threshold, std::string text) : Threshold(threshold), Text(text) {} 58 | FloatSegment() = default; 59 | }; 60 | 61 | DECLARE_JSON_STRUCT(BadCutDisplay) { 62 | NAMED_VALUE(std::string, Text, "text"); 63 | NAMED_VALUE_DEFAULT(std::string, Type, "type", BadCutTypes[0]); 64 | NAMED_VALUE(ColorArray, Color, "color"); 65 | 66 | DESERIALIZE_FUNCTION(ValidateType) { 67 | if (std::find(BadCutTypes.begin(), BadCutTypes.end(), Type) == BadCutTypes.end()) 68 | throw JSONException("invalid display type"); 69 | } 70 | }; 71 | 72 | DECLARE_JSON_STRUCT(MissDisplay) { 73 | NAMED_VALUE(std::string, Text, "text"); 74 | NAMED_VALUE(ColorArray, Color, "color"); 75 | }; 76 | 77 | DECLARE_JSON_STRUCT(Config) { 78 | NAMED_VECTOR(Judgement, Judgements, "judgments"); 79 | DESERIALIZE_FUNCTION(ValidateJudgements) { 80 | if (Judgements.size() < 1) 81 | throw JSONException("no judgements found in config"); 82 | }; 83 | NAMED_VECTOR_DEFAULT(Judgement, ChainHeadJudgements, {}, "chainHeadJudgments"); 84 | NAMED_VALUE_OPTIONAL(Judgement, ChainLinkDisplay, "chainLinkDisplay"); 85 | NAMED_VECTOR_DEFAULT(Segment, BeforeCutAngleSegments, {}, "beforeCutAngleJudgments"); 86 | NAMED_VECTOR_DEFAULT(Segment, AccuracySegments, {}, "accuracyJudgments"); 87 | NAMED_VECTOR_DEFAULT(Segment, AfterCutAngleSegments, {}, "afterCutAngleJudgments"); 88 | NAMED_VECTOR_DEFAULT(FloatSegment, TimeDependenceSegments, {}, "timeDependencyJudgments"); 89 | NAMED_VALUE_OPTIONAL(float, FixedPosX, "fixedPosX"); 90 | NAMED_VALUE_OPTIONAL(float, FixedPosY, "fixedPosY"); 91 | NAMED_VALUE_OPTIONAL(float, FixedPosZ, "fixedPosZ"); 92 | NAMED_VALUE_OPTIONAL(bool, UseFixedPos, "useFixedPos"); 93 | NAMED_VALUE_OPTIONAL(ConfigUtils::Vector3, UnprocessedFixedPos, "fixedPosition"); 94 | NAMED_VALUE_OPTIONAL(ConfigUtils::Vector3, UnprocessedPosOffset, "targetPositionOffset"); 95 | NAMED_VALUE_DEFAULT(int, TimeDependenceDecimalPrecision, 1, "timeDependencyDecimalPrecision"); 96 | NAMED_VALUE_DEFAULT(int, TimeDependenceDecimalOffset, 2, "timeDependencyDecimalOffset"); 97 | NAMED_VECTOR_DEFAULT(BadCutDisplay, BadCutDisplays, {}, "badCutDisplays"); 98 | NAMED_VALUE_DEFAULT(bool, RandomizeBadCutDisplays, true, "randomizeBadCutDisplays"); 99 | NAMED_VECTOR_DEFAULT(MissDisplay, MissDisplays, {}, "missDisplays"); 100 | NAMED_VALUE_DEFAULT(bool, RandomizeMissDisplays, true, "randomizeMissDisplays"); 101 | 102 | std::optional FixedPos; 103 | std::optional PosOffset; 104 | 105 | std::vector WrongDirections; 106 | std::vector WrongColors; 107 | std::vector Bombs; 108 | 109 | DESERIALIZE_FUNCTION(ConvertPositions) { 110 | if (UseFixedPos.has_value() && UseFixedPos.value()) 111 | FixedPos = {FixedPosX.value_or(0), FixedPosY.value_or(0), FixedPosZ.value_or(0)}; 112 | else if (UnprocessedFixedPos.has_value()) 113 | FixedPos = {UnprocessedFixedPos->x, UnprocessedFixedPos->y, UnprocessedFixedPos->z}; 114 | if (UnprocessedPosOffset) 115 | PosOffset = {UnprocessedPosOffset->x, UnprocessedPosOffset->y, UnprocessedPosOffset->z}; 116 | }; 117 | 118 | DESERIALIZE_FUNCTION(CategorizeBadCuts) { 119 | WrongDirections.clear(); 120 | WrongColors.clear(); 121 | Bombs.clear(); 122 | for (auto& display : BadCutDisplays) { 123 | if (display.Type == BadCutTypes[0] || display.Type == BadCutTypes[1]) 124 | WrongDirections.emplace_back(display); 125 | if (display.Type == BadCutTypes[0] || display.Type == BadCutTypes[2]) 126 | WrongColors.emplace_back(display); 127 | if (display.Type == BadCutTypes[0] || display.Type == BadCutTypes[3]) 128 | Bombs.emplace_back(display); 129 | } 130 | }; 131 | 132 | bool HasChainHead() { 133 | return ChainHeadJudgements.size() > 0; 134 | }; 135 | bool HasChainLink() { 136 | return ChainLinkDisplay.has_value(); 137 | }; 138 | }; 139 | } 140 | -------------------------------------------------------------------------------- /include/json/DefaultConfig.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "Config.hpp" 4 | 5 | inline HSV::Config const defaultConfig = { 6 | .Judgements = 7 | { 8 | {115, "%s", {1, 1, 1, 1}}, 9 | {110, "%B%C%s%A", {0, 0.5, 1, 1}}, 10 | {105, "%B%C%s%A", {0, 1, 0, 1}}, 11 | {100, "%B%C%s%A", {1, 1, 0, 1}}, 12 | {50, "%B%s%A", {1, 0, 0, 1}, true}, 13 | {0, "%B%s%A", {1, 0, 0, 1}}, 14 | }, 15 | .ChainHeadJudgements = 16 | { 17 | {85, "%s", {1, 1, 1, 1}}, 18 | {80, "%B%C%s%A", {0, 0.5, 1, 1}}, 19 | {75, "%B%C%s%A", {0, 1, 0, 1}}, 20 | {70, "%B%C%s%A", {1, 1, 0, 1}}, 21 | {35, "%B%s%A", {1, 0, 0, 1}, true}, 22 | {0, "%B%s%A", {1, 0, 0, 1}}, 23 | }, 24 | .ChainLinkDisplay = {{0, "%s", {1, 1, 1, 1}}}, 25 | .BeforeCutAngleSegments = 26 | { 27 | {70, " + "}, 28 | {0, " - "}, 29 | }, 30 | .AccuracySegments = 31 | { 32 | {15, ""}, 33 | {0, ""}, 34 | }, 35 | .AfterCutAngleSegments = 36 | { 37 | {30, " + "}, 38 | {0, " - "}, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /mod.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "_QPVersion": "0.1.1", 3 | "name": "${mod_name}", 4 | "id": "${mod_id}", 5 | "author": "Christoffyw, Metalit", 6 | "version": "${version}", 7 | "packageId": "com.beatgames.beatsaber", 8 | "packageVersion": "1.40.4_5283", 9 | "description": "Changes the scores that pop up when you hit notes", 10 | "coverImage": "Cover.jpg", 11 | "dependencies": [], 12 | "modFiles": [], 13 | "libraryFiles": [], 14 | "fileCopies": [], 15 | "copyExtensions": [] 16 | } 17 | -------------------------------------------------------------------------------- /qpm.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "sharedDir": "shared", 4 | "dependenciesDir": "extern", 5 | "info": { 6 | "name": "HitScoreVisualizer", 7 | "id": "HitScoreVisualizer", 8 | "version": "3.0.0", 9 | "url": null, 10 | "additionalData": { 11 | "overrideSoName": "libhitscorevisualizer.so" 12 | } 13 | }, 14 | "workspace": { 15 | "scripts": { 16 | "build": [ 17 | "pwsh ./scripts/build.ps1 $0?" 18 | ], 19 | "copy": [ 20 | "pwsh ./scripts/copy.ps1 $0:?", 21 | "pwsh ./scripts/restart-game.ps1" 22 | ], 23 | "log": [ 24 | "pwsh ./scripts/start-logging.ps1 $0:?" 25 | ], 26 | "qmod": [ 27 | "pwsh ./scripts/build.ps1 $0?", 28 | "pwsh ./scripts/createqmod.ps1" 29 | ], 30 | "restart": [ 31 | "pwsh ./scripts/restart-game.ps1" 32 | ], 33 | "stack": [ 34 | "pwsh ./scripts/ndk-stack.ps1 $0:?" 35 | ], 36 | "tomb": [ 37 | "pwsh ./scripts/pull-tombstone.ps1 -analyze" 38 | ] 39 | }, 40 | "qmodIncludeDirs": [], 41 | "qmodIncludeFiles": [], 42 | "qmodOutput": null 43 | }, 44 | "dependencies": [ 45 | { 46 | "id": "beatsaber-hook", 47 | "versionRange": "^6.0.0", 48 | "additionalData": {} 49 | }, 50 | { 51 | "id": "scotland2", 52 | "versionRange": "^0.1.6", 53 | "additionalData": { 54 | "includeQmod": false 55 | } 56 | }, 57 | { 58 | "id": "bs-cordl", 59 | "versionRange": "^4005.0.0", 60 | "additionalData": {} 61 | }, 62 | { 63 | "id": "bsml", 64 | "versionRange": "^0.4.49", 65 | "additionalData": {} 66 | }, 67 | { 68 | "id": "custom-types", 69 | "versionRange": "^0.18.0", 70 | "additionalData": {} 71 | }, 72 | { 73 | "id": "rapidjson-macros", 74 | "versionRange": "^2.0.0", 75 | "additionalData": {} 76 | }, 77 | { 78 | "id": "paper2_scotland2", 79 | "versionRange": "^4.5.0", 80 | "additionalData": {} 81 | }, 82 | { 83 | "id": "config-utils", 84 | "versionRange": "^2.0.2", 85 | "additionalData": {} 86 | }, 87 | { 88 | "id": "metacore", 89 | "versionRange": "^1.2.2", 90 | "additionalData": {} 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /qpm.shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "version": "0.1.0", 4 | "sharedDir": "shared", 5 | "dependenciesDir": "extern", 6 | "info": { 7 | "name": "HitScoreVisualizer", 8 | "id": "HitScoreVisualizer", 9 | "version": "3.0.0", 10 | "url": null, 11 | "additionalData": { 12 | "overrideSoName": "libhitscorevisualizer.so" 13 | } 14 | }, 15 | "workspace": { 16 | "scripts": { 17 | "build": [ 18 | "pwsh ./scripts/build.ps1 $0?" 19 | ], 20 | "copy": [ 21 | "pwsh ./scripts/copy.ps1 $0:?", 22 | "pwsh ./scripts/restart-game.ps1" 23 | ], 24 | "log": [ 25 | "pwsh ./scripts/start-logging.ps1 $0:?" 26 | ], 27 | "qmod": [ 28 | "pwsh ./scripts/build.ps1 $0?", 29 | "pwsh ./scripts/createqmod.ps1" 30 | ], 31 | "restart": [ 32 | "pwsh ./scripts/restart-game.ps1" 33 | ], 34 | "stack": [ 35 | "pwsh ./scripts/ndk-stack.ps1 $0:?" 36 | ], 37 | "tomb": [ 38 | "pwsh ./scripts/pull-tombstone.ps1 -analyze" 39 | ] 40 | }, 41 | "qmodIncludeDirs": [], 42 | "qmodIncludeFiles": [], 43 | "qmodOutput": null 44 | }, 45 | "dependencies": [ 46 | { 47 | "id": "beatsaber-hook", 48 | "versionRange": "^6.0.0", 49 | "additionalData": {} 50 | }, 51 | { 52 | "id": "scotland2", 53 | "versionRange": "^0.1.6", 54 | "additionalData": { 55 | "includeQmod": false 56 | } 57 | }, 58 | { 59 | "id": "bs-cordl", 60 | "versionRange": "^4005.0.0", 61 | "additionalData": {} 62 | }, 63 | { 64 | "id": "bsml", 65 | "versionRange": "^0.4.49", 66 | "additionalData": {} 67 | }, 68 | { 69 | "id": "custom-types", 70 | "versionRange": "^0.18.0", 71 | "additionalData": {} 72 | }, 73 | { 74 | "id": "rapidjson-macros", 75 | "versionRange": "^2.0.0", 76 | "additionalData": {} 77 | }, 78 | { 79 | "id": "paper2_scotland2", 80 | "versionRange": "^4.5.0", 81 | "additionalData": {} 82 | }, 83 | { 84 | "id": "config-utils", 85 | "versionRange": "^2.0.2", 86 | "additionalData": {} 87 | }, 88 | { 89 | "id": "metacore", 90 | "versionRange": "^1.2.2", 91 | "additionalData": {} 92 | } 93 | ] 94 | }, 95 | "restoredDependencies": [ 96 | { 97 | "dependency": { 98 | "id": "metacore", 99 | "versionRange": "=1.2.2", 100 | "additionalData": { 101 | "soLink": "https://github.com/Metalit/MetaCore/releases/download/v1.2.2/libmetacore.so", 102 | "overrideSoName": "libmetacore.so", 103 | "modLink": "https://github.com/Metalit/MetaCore/releases/download/v1.2.2/MetaCore.qmod", 104 | "branchName": "version/v1_2_2", 105 | "cmake": true 106 | } 107 | }, 108 | "version": "1.2.2" 109 | }, 110 | { 111 | "dependency": { 112 | "id": "paper2_scotland2", 113 | "versionRange": "=4.6.4", 114 | "additionalData": { 115 | "soLink": "https://github.com/Fernthedev/paperlog/releases/download/v4.6.4/libpaper2_scotland2.so", 116 | "overrideSoName": "libpaper2_scotland2.so", 117 | "modLink": "https://github.com/Fernthedev/paperlog/releases/download/v4.6.4/paper2_scotland2.qmod", 118 | "branchName": "version/v4_6_4", 119 | "compileOptions": { 120 | "systemIncludes": [ 121 | "shared/utfcpp/source" 122 | ] 123 | }, 124 | "cmake": false 125 | } 126 | }, 127 | "version": "4.6.4" 128 | }, 129 | { 130 | "dependency": { 131 | "id": "rapidjson-macros", 132 | "versionRange": "=2.1.0", 133 | "additionalData": { 134 | "headersOnly": true, 135 | "branchName": "version/v2_1_0", 136 | "cmake": false 137 | } 138 | }, 139 | "version": "2.1.0" 140 | }, 141 | { 142 | "dependency": { 143 | "id": "config-utils", 144 | "versionRange": "=2.0.3", 145 | "additionalData": { 146 | "headersOnly": true, 147 | "soLink": "https://github.com/darknight1050/config-utils/releases/download/v2.0.3/libconfig-utils_test.so", 148 | "overrideSoName": "libconfig-utils_test.so", 149 | "branchName": "version/v2_0_3", 150 | "cmake": true 151 | } 152 | }, 153 | "version": "2.0.3" 154 | }, 155 | { 156 | "dependency": { 157 | "id": "custom-types", 158 | "versionRange": "=0.18.3", 159 | "additionalData": { 160 | "soLink": "https://github.com/QuestPackageManager/Il2CppQuestTypePatching/releases/download/v0.18.3/libcustom-types.so", 161 | "debugSoLink": "https://github.com/QuestPackageManager/Il2CppQuestTypePatching/releases/download/v0.18.3/debug_libcustom-types.so", 162 | "overrideSoName": "libcustom-types.so", 163 | "modLink": "https://github.com/QuestPackageManager/Il2CppQuestTypePatching/releases/download/v0.18.3/CustomTypes.qmod", 164 | "branchName": "version/v0_18_3", 165 | "compileOptions": { 166 | "cppFlags": [ 167 | "-Wno-invalid-offsetof" 168 | ] 169 | }, 170 | "cmake": true 171 | } 172 | }, 173 | "version": "0.18.3" 174 | }, 175 | { 176 | "dependency": { 177 | "id": "bsml", 178 | "versionRange": "=0.4.52", 179 | "additionalData": { 180 | "soLink": "https://github.com/bsq-ports/Quest-BSML/releases/download/v0.4.52/libbsml.so", 181 | "debugSoLink": "https://github.com/bsq-ports/Quest-BSML/releases/download/v0.4.52/debug_libbsml.so", 182 | "overrideSoName": "libbsml.so", 183 | "modLink": "https://github.com/bsq-ports/Quest-BSML/releases/download/v0.4.52/BSML.qmod", 184 | "branchName": "version/v0_4_52", 185 | "cmake": true 186 | } 187 | }, 188 | "version": "0.4.52" 189 | }, 190 | { 191 | "dependency": { 192 | "id": "libil2cpp", 193 | "versionRange": "=0.4.0", 194 | "additionalData": { 195 | "headersOnly": true, 196 | "compileOptions": { 197 | "systemIncludes": [ 198 | "il2cpp/external/baselib/Include", 199 | "il2cpp/external/baselib/Platforms/Android/Include" 200 | ] 201 | } 202 | } 203 | }, 204 | "version": "0.4.0" 205 | }, 206 | { 207 | "dependency": { 208 | "id": "bs-cordl", 209 | "versionRange": "=4005.0.0", 210 | "additionalData": { 211 | "headersOnly": true, 212 | "branchName": "version/v4005_0_0", 213 | "compileOptions": { 214 | "includePaths": [ 215 | "include" 216 | ], 217 | "cppFeatures": [], 218 | "cppFlags": [ 219 | "-DNEED_UNSAFE_CSHARP", 220 | "-fdeclspec", 221 | "-DUNITY_2021", 222 | "-DHAS_CODEGEN", 223 | "-Wno-invalid-offsetof" 224 | ] 225 | } 226 | } 227 | }, 228 | "version": "4005.0.0" 229 | }, 230 | { 231 | "dependency": { 232 | "id": "beatsaber-hook", 233 | "versionRange": "=6.4.2", 234 | "additionalData": { 235 | "soLink": "https://github.com/QuestPackageManager/beatsaber-hook/releases/download/v6.4.2/libbeatsaber-hook.so", 236 | "debugSoLink": "https://github.com/QuestPackageManager/beatsaber-hook/releases/download/v6.4.2/debug_libbeatsaber-hook.so", 237 | "overrideSoName": "libbeatsaber-hook.so", 238 | "modLink": "https://github.com/QuestPackageManager/beatsaber-hook/releases/download/v6.4.2/beatsaber-hook.qmod", 239 | "branchName": "version/v6_4_2", 240 | "compileOptions": { 241 | "cppFlags": [ 242 | "-Wno-extra-qualification" 243 | ] 244 | }, 245 | "cmake": true 246 | } 247 | }, 248 | "version": "6.4.2" 249 | }, 250 | { 251 | "dependency": { 252 | "id": "scotland2", 253 | "versionRange": "=0.1.6", 254 | "additionalData": { 255 | "soLink": "https://github.com/sc2ad/scotland2/releases/download/v0.1.6/libsl2.so", 256 | "debugSoLink": "https://github.com/sc2ad/scotland2/releases/download/v0.1.6/debug_libsl2.so", 257 | "overrideSoName": "libsl2.so", 258 | "branchName": "version/v0_1_6" 259 | } 260 | }, 261 | "version": "0.1.6" 262 | }, 263 | { 264 | "dependency": { 265 | "id": "tinyxml2", 266 | "versionRange": "=10.0.0", 267 | "additionalData": { 268 | "soLink": "https://github.com/MillzyDev/NDK-tinyxml2/releases/download/v10.0.0/libtinyxml2.so", 269 | "debugSoLink": "https://github.com/MillzyDev/NDK-tinyxml2/releases/download/v10.0.0/debug_libtinyxml2.so", 270 | "overrideSoName": "libtinyxml2.so", 271 | "modLink": "https://github.com/MillzyDev/NDK-tinyxml2/releases/download/v10.0.0/tinyxml2.qmod", 272 | "branchName": "version/v10_0_0", 273 | "cmake": true 274 | } 275 | }, 276 | "version": "10.0.0" 277 | }, 278 | { 279 | "dependency": { 280 | "id": "fmt", 281 | "versionRange": "=11.0.2", 282 | "additionalData": { 283 | "headersOnly": true, 284 | "branchName": "version/v11_0_2", 285 | "compileOptions": { 286 | "systemIncludes": [ 287 | "fmt/include/" 288 | ], 289 | "cppFlags": [ 290 | "-DFMT_HEADER_ONLY" 291 | ] 292 | } 293 | } 294 | }, 295 | "version": "11.0.2" 296 | } 297 | ] 298 | } -------------------------------------------------------------------------------- /scripts/build.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [Switch] $clean, 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $help 7 | ) 8 | 9 | if ($help -eq $true) { 10 | Write-Output "`"Build`" - Copiles your mod into a `".so`" or a `".a`" library" 11 | Write-Output "`n-- Arguments --`n" 12 | 13 | Write-Output "-Clean `t`t Deletes the `"build`" folder, so that the entire library is rebuilt" 14 | 15 | exit 16 | } 17 | 18 | # if user specified clean, remove all build files 19 | if ($clean.IsPresent) { 20 | if (Test-Path -Path "build") { 21 | remove-item build -R 22 | } 23 | } 24 | 25 | 26 | if (($clean.IsPresent) -or (-not (Test-Path -Path "build"))) { 27 | new-item -Path build -ItemType Directory 28 | } 29 | 30 | & cmake -G "Ninja" -DCMAKE_BUILD_TYPE="RelWithDebInfo" -B build 31 | & cmake --build ./build 32 | -------------------------------------------------------------------------------- /scripts/copy.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [Switch] $clean, 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $log, 7 | 8 | [Parameter(Mandatory=$false)] 9 | [Switch] $useDebug, 10 | 11 | [Parameter(Mandatory=$false)] 12 | [Switch] $self, 13 | 14 | [Parameter(Mandatory=$false)] 15 | [Switch] $all, 16 | 17 | [Parameter(Mandatory=$false)] 18 | [String] $custom="", 19 | 20 | [Parameter(Mandatory=$false)] 21 | [String] $file="", 22 | 23 | [Parameter(Mandatory=$false)] 24 | [Switch] $help 25 | ) 26 | 27 | if ($help -eq $true) { 28 | Write-Output "`"Copy`" - Builds and copies your mod to your quest, and also starts Beat Saber with optional logging" 29 | Write-Output "`n-- Arguments --`n" 30 | 31 | Write-Output "-Clean `t`t Performs a clean build (equvilant to running `"build -clean`")" 32 | Write-Output "-UseDebug `t Copies the debug version of the mod to your quest" 33 | Write-Output "-Log `t`t Logs Beat Saber using the `"Start-Logging`" command" 34 | 35 | Write-Output "`n-- Logging Arguments --`n" 36 | 37 | & $PSScriptRoot/start-logging.ps1 -help -excludeHeader 38 | 39 | exit 40 | } 41 | 42 | & $PSScriptRoot/build.ps1 -clean:$clean 43 | 44 | if ($LASTEXITCODE -ne 0) { 45 | Write-Output "Failed to build, exiting..." 46 | exit $LASTEXITCODE 47 | } 48 | 49 | & $PSScriptRoot/validate-modjson.ps1 50 | if ($LASTEXITCODE -ne 0) { 51 | exit $LASTEXITCODE 52 | } 53 | $modJson = Get-Content "./mod.json" -Raw | ConvertFrom-Json 54 | 55 | foreach ($fileName in $modJson.modFiles) { 56 | if ($useDebug -eq $true) { 57 | & adb push build/debug/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/early_mods/$fileName 58 | } else { 59 | & adb push build/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/early_mods/$fileName 60 | } 61 | } 62 | 63 | foreach ($fileName in $modJson.lateModFiles) { 64 | if ($useDebug -eq $true) { 65 | & adb push build/debug/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/mods/$fileName 66 | } else { 67 | & adb push build/$fileName /sdcard/ModData/com.beatgames.beatsaber/Modloader/mods/$fileName 68 | } 69 | } 70 | 71 | if ($log -eq $true) { 72 | & adb logcat -c 73 | & $PSScriptRoot/start-logging.ps1 -self:$self -all:$all -custom:$custom -file:$file 74 | } 75 | -------------------------------------------------------------------------------- /scripts/createqmod.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [String] $qmodName="", 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $help 7 | ) 8 | 9 | if ($help -eq $true) { 10 | Write-Output "`"createqmod`" - Creates a .qmod file with your compiled libraries and mod.json." 11 | Write-Output "`n-- Arguments --`n" 12 | 13 | Write-Output "-QmodName `t The file name of your qmod" 14 | 15 | exit 16 | } 17 | 18 | $mod = "./mod.json" 19 | 20 | & $PSScriptRoot/validate-modjson.ps1 21 | if ($LASTEXITCODE -ne 0) { 22 | exit $LASTEXITCODE 23 | } 24 | $modJson = Get-Content $mod -Raw | ConvertFrom-Json 25 | 26 | if ($qmodName -eq "") { 27 | $qmodName = $modJson.name 28 | } 29 | 30 | $filelist = @($mod) 31 | 32 | $cover = "./" + $modJson.coverImage 33 | if ((-not ($cover -eq "./")) -and (Test-Path $cover)) { 34 | $filelist += ,$cover 35 | } 36 | 37 | foreach ($mod in $modJson.modFiles) { 38 | $path = "./build/" + $mod 39 | if (-not (Test-Path $path)) { 40 | $path = "./extern/libs/" + $mod 41 | } 42 | if (-not (Test-Path $path)) { 43 | Write-Output "Error: could not find dependency: $path" 44 | exit 1 45 | } 46 | $filelist += $path 47 | } 48 | 49 | foreach ($mod in $modJson.lateModFiles) { 50 | $path = "./build/" + $mod 51 | if (-not (Test-Path $path)) { 52 | $path = "./extern/libs/" + $mod 53 | } 54 | if (-not (Test-Path $path)) { 55 | Write-Output "Error: could not find dependency: $path" 56 | exit 1 57 | } 58 | $filelist += $path 59 | } 60 | 61 | foreach ($lib in $modJson.libraryFiles) { 62 | $path = "./build/" + $lib 63 | if (-not (Test-Path $path)) { 64 | $path = "./extern/libs/" + $lib 65 | } 66 | if (-not (Test-Path $path)) { 67 | Write-Output "Error: could not find dependency: $path" 68 | exit 1 69 | } 70 | $filelist += $path 71 | } 72 | 73 | $zip = $qmodName + ".zip" 74 | $qmod = $qmodName + ".qmod" 75 | 76 | Compress-Archive -Path $filelist -DestinationPath $zip -Update 77 | Start-Sleep 1 78 | Move-Item $zip $qmod -Force 79 | -------------------------------------------------------------------------------- /scripts/ndk-stack.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [String] $logName = "log.log", 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $help 7 | ) 8 | 9 | if ($help -eq $true) { 10 | Write-Output "`"NDK-Stack`" - Processes a tombstone using the debug .so to find file locations" 11 | Write-Output "`n-- Arguments --`n" 12 | 13 | Write-Output "LogName `t`t The file name of the tombstone to process" 14 | 15 | exit 16 | } 17 | 18 | if (Test-Path "./ndkpath.txt") { 19 | $NDKPath = Get-Content ./ndkpath.txt 20 | } else { 21 | $NDKPath = $ENV:ANDROID_NDK_HOME 22 | } 23 | 24 | $stackScript = "$NDKPath/ndk-stack" 25 | if (-not ($PSVersionTable.PSEdition -eq "Core")) { 26 | $stackScript += ".cmd" 27 | } 28 | 29 | Get-Content $logName | & $stackScript -sym ./build/debug/ > "$($logName)_processed.log" 30 | -------------------------------------------------------------------------------- /scripts/pull-tombstone.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [String] $fileName = "RecentCrash.log", 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $analyze, 7 | 8 | [Parameter(Mandatory=$false)] 9 | [Switch] $help 10 | ) 11 | 12 | if ($help -eq $true) { 13 | Write-Output "`"Pull-Tombstone`" - Finds and pulls the most recent tombstone from your quest, optionally analyzing it with ndk-stack" 14 | Write-Output "`n-- Arguments --`n" 15 | 16 | Write-Output "-FileName `t The name for the output file, defaulting to RecentCrash.log" 17 | Write-Output "-Analyze `t Runs ndk-stack on the file after pulling" 18 | 19 | exit 20 | } 21 | 22 | $global:currentDate = get-date 23 | $global:recentDate = $Null 24 | $global:recentTombstone = $Null 25 | 26 | for ($i = 0; $i -lt 3; $i++) { 27 | $stats = & adb shell stat /sdcard/Android/data/com.beatgames.beatsaber/files/tombstone_0$i 28 | $date = (Select-String -Input $stats -Pattern "(?<=Modify: )\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?=.\d{9})").Matches.Value 29 | if([string]::IsNullOrEmpty($date)) { 30 | Write-Output "Failed to pull tombstone, exiting..." 31 | exit 1; 32 | } 33 | $dateObj = [datetime]::ParseExact($date, "yyyy-MM-dd HH:mm:ss", $Null) 34 | $difference = [math]::Round(($currentDate - $dateObj).TotalMinutes) 35 | if ($difference -eq 1) { 36 | Write-Output "Found tombstone_0$i $difference minute ago" 37 | } else { 38 | Write-Output "Found tombstone_0$i $difference minutes ago" 39 | } 40 | if (-not $recentDate -or $recentDate -lt $dateObj) { 41 | $recentDate = $dateObj 42 | $recentTombstone = $i 43 | } 44 | } 45 | 46 | Write-Output "Latest tombstone was tombstone_0$recentTombstone" 47 | 48 | & adb pull /sdcard/Android/data/com.beatgames.beatsaber/files/tombstone_0$recentTombstone $fileName 49 | 50 | if ($analyze) { 51 | & $PSScriptRoot/ndk-stack.ps1 -logName:$fileName 52 | } 53 | -------------------------------------------------------------------------------- /scripts/restart-game.ps1: -------------------------------------------------------------------------------- 1 | adb shell am force-stop com.beatgames.beatsaber 2 | adb shell am start com.beatgames.beatsaber/com.unity3d.player.UnityPlayerActivity 3 | Start-Sleep 1 4 | adb shell am start com.beatgames.beatsaber/com.unity3d.player.UnityPlayerActivity | Out-Null 5 | -------------------------------------------------------------------------------- /scripts/start-logging.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory=$false)] 3 | [Switch] $self, 4 | 5 | [Parameter(Mandatory=$false)] 6 | [Switch] $all, 7 | 8 | [Parameter(Mandatory=$false)] 9 | [String] $custom="", 10 | 11 | [Parameter(Mandatory=$false)] 12 | [String] $file="", 13 | 14 | [Parameter(Mandatory=$false)] 15 | [Switch] $help, 16 | 17 | [Parameter(Mandatory=$false)] 18 | [Switch] $trim, 19 | 20 | [Parameter(Mandatory=$false)] 21 | [Switch] $excludeHeader 22 | ) 23 | 24 | if ($help -eq $true) { 25 | if ($excludeHeader -eq $false) { 26 | Write-Output "`"Start-Logging`" - Logs Beat Saber using `"adb logcat`"" 27 | Write-Output "`n-- Arguments --`n" 28 | } 29 | 30 | Write-Output "-Self `t`t Only logs from your mod and crashes" 31 | Write-Output "-All `t`t Logs everything, including from non Beat Saber processes" 32 | Write-Output "-Custom `t Specify a specific logging pattern, e.g `"custom-types|questui`"" 33 | Write-Output "`t`t NOTE: The paterent `"AndriodRuntime|CRASH`" is always appended to a custom pattern" 34 | Write-Output "-Trim `t Removes time, level, and mod from the start of lines`"" 35 | Write-Output "-File `t`t Saves the output of the log to the file name given" 36 | 37 | exit 38 | } 39 | 40 | $bspid = adb shell pidof com.beatgames.beatsaber 41 | $command = "adb logcat " 42 | 43 | if ($all -eq $false) { 44 | $loops = 0 45 | while ([string]::IsNullOrEmpty($bspid) -and $loops -lt 3) { 46 | Start-Sleep -Milliseconds 100 47 | $bspid = adb shell pidof com.beatgames.beatsaber 48 | $loops += 1 49 | } 50 | 51 | if ([string]::IsNullOrEmpty($bspid)) { 52 | Write-Output "Could not connect to adb, exiting..." 53 | exit 1 54 | } 55 | 56 | $command += "--pid $bspid" 57 | } 58 | 59 | if ($all -eq $false) { 60 | $pattern = "(" 61 | if ($self -eq $true) { 62 | $modID = (Get-Content "./mod.json" -Raw | ConvertFrom-Json).id 63 | $pattern += "$modID|" 64 | } 65 | if (![string]::IsNullOrEmpty($custom)) { 66 | $pattern += "$custom|" 67 | } 68 | if ($pattern -eq "(") { 69 | $pattern = "(QuestHook|modloader|" 70 | } 71 | $pattern += "AndroidRuntime|CRASH)" 72 | $command += " | Select-String -pattern `"$pattern`"" 73 | } 74 | 75 | if ($trim -eq $true) { 76 | $command += " | % {`$_ -replace `"^(?(?=.*\]:).*?\]: |.*?: )`", `"`"}" 77 | } 78 | 79 | if (![string]::IsNullOrEmpty($file)) { 80 | $command += " | Out-File -FilePath $PSScriptRoot\$file" 81 | } 82 | 83 | Write-Output "Logging using Command `"$command`"" 84 | Invoke-Expression $command 85 | -------------------------------------------------------------------------------- /scripts/validate-modjson.ps1: -------------------------------------------------------------------------------- 1 | $mod = "./mod.json" 2 | $modTemplate = "./mod.template.json" 3 | $qpmShared = "./qpm.shared.json" 4 | 5 | if (Test-Path -Path $modTemplate) { 6 | $update = -not (Test-Path -Path $mod) 7 | if (-not $update) { 8 | $update = (Get-Item $modTemplate).LastWriteTime -gt (Get-Item $mod).LastWriteTime 9 | } 10 | if (-not $update -and (Test-Path -Path $qpmShared)) { 11 | $update = (Get-Item $qpmShared).LastWriteTime -gt (Get-Item $mod).LastWriteTime 12 | } 13 | 14 | if ($update) { 15 | & qpm qmod manifest 16 | if ($LASTEXITCODE -ne 0) { 17 | exit $LASTEXITCODE 18 | } 19 | } 20 | } 21 | elseif (-not (Test-Path -Path $mod)) { 22 | Write-Output "Error: mod.json and mod.template.json were not present" 23 | exit 1 24 | } 25 | 26 | $psVersion = $PSVersionTable.PSVersion.Major 27 | if ($psVersion -ge 6) { 28 | $schemaUrl = "https://raw.githubusercontent.com/Lauriethefish/QuestPatcher.QMod/main/QuestPatcher.QMod/Resources/qmod.schema.json" 29 | Invoke-WebRequest $schemaUrl -OutFile ./mod.schema.json 30 | 31 | $schema = "./mod.schema.json" 32 | $modJsonRaw = Get-Content $mod -Raw 33 | $modSchemaRaw = Get-Content $schema -Raw 34 | 35 | Remove-Item $schema 36 | 37 | Write-Output "Validating mod.json..." 38 | if (-not ($modJsonRaw | Test-Json -Schema $modSchemaRaw)) { 39 | Write-Output "Error: mod.json is not valid" 40 | exit 1 41 | } 42 | } 43 | else { 44 | Write-Output "Could not validate mod.json with schema: powershell version was too low (< 6)" 45 | } 46 | exit 47 | -------------------------------------------------------------------------------- /src/Judgments.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "Config.hpp" 4 | #include "GlobalNamespace/CutScoreBuffer.hpp" 5 | #include "GlobalNamespace/IReadonlyCutScoreBuffer.hpp" 6 | #include "GlobalNamespace/NoteData.hpp" 7 | #include "GlobalNamespace/ScoreModel.hpp" 8 | #include "Main.hpp" 9 | #include "System/Collections/Generic/Dictionary_2.hpp" 10 | #include "TMPro/TextMeshPro.hpp" 11 | #include "UnityEngine/Mathf.hpp" 12 | #include "metacore/shared/operators.hpp" 13 | 14 | using namespace HSV; 15 | using ScoringType = GlobalNamespace::NoteData::ScoringType; 16 | 17 | enum class Direction { Up, UpRight, Right, DownRight, Down, DownLeft, Left, UpLeft, None }; 18 | 19 | static float const angle = sqrt(2) / 2; 20 | 21 | static std::map const normalsMap = { 22 | {Direction::DownRight, {angle, -angle, 0}}, 23 | {Direction::Down, {0, -1, 0}}, 24 | {Direction::DownLeft, {-angle, -angle, 0}}, 25 | {Direction::Left, {-1, 0, 0}}, 26 | }; 27 | 28 | static Direction GetWrongDirection(GlobalNamespace::NoteCutInfo const& cutInfo) { 29 | float best = std::numeric_limits::min(); 30 | Direction ret = Direction::None; 31 | for (auto& [direction, normal] : normalsMap) { 32 | float compare = abs(UnityEngine::Vector3::Dot(cutInfo.cutNormal, normal)); 33 | if (compare > best) { 34 | best = compare; 35 | ret = direction; 36 | } 37 | } 38 | if (ret == Direction::None) 39 | return ret; 40 | if (UnityEngine::Vector3::Dot(normalsMap.at(ret), UnityEngine::Vector3::op_Subtraction(cutInfo.notePosition, cutInfo.cutPoint)) > 0) 41 | return ret; 42 | int asInt = (int) ret; 43 | if (asInt < 4) 44 | return (Direction) (asInt + 4); 45 | return (Direction) (asInt - 4); 46 | } 47 | 48 | static std::string_view GetDirectionText(Direction wrongDirection) { 49 | switch (wrongDirection) { 50 | case Direction::Up: 51 | return "↑"; 52 | case Direction::UpRight: 53 | return "↗"; 54 | case Direction::Right: 55 | return "→"; 56 | case Direction::DownRight: 57 | return "↘"; 58 | case Direction::Down: 59 | return "↓"; 60 | case Direction::DownLeft: 61 | return "↙"; 62 | case Direction::Left: 63 | return "←"; 64 | case Direction::UpLeft: 65 | return "↖"; 66 | default: 67 | return ""; 68 | } 69 | } 70 | 71 | static Judgement& GetBestJudgement(std::vector& judgements, int comparison) { 72 | Judgement* best = nullptr; 73 | for (auto& judgement : judgements) { 74 | if (comparison >= judgement.Threshold && (!best || judgement.Threshold > best->Threshold)) 75 | best = &judgement; 76 | } 77 | return best ? *best : judgements.back(); 78 | } 79 | 80 | static std::string GetBestSegmentText(std::vector& segments, int comparison) { 81 | Segment* best = nullptr; 82 | for (auto& segment : segments) { 83 | if (comparison >= segment.Threshold && (!best || segment.Threshold > best->Threshold)) 84 | best = &segment; 85 | } 86 | return best ? best->Text : ""; 87 | } 88 | 89 | static std::string GetBestFloatSegmentText(std::vector& segments, float comparison) { 90 | FloatSegment* best = nullptr; 91 | for (auto& segment : segments) { 92 | if (comparison >= segment.Threshold && (!best || segment.Threshold > best->Threshold)) 93 | best = &segment; 94 | } 95 | return best ? best->Text : ""; 96 | } 97 | 98 | static std::string TimeDependenceString(float timeDependence) { 99 | int multiplier = std::pow(10, getGlobalConfig().CurrentConfig.TimeDependenceDecimalOffset); 100 | std::stringstream ss; 101 | ss << std::fixed << std::setprecision(getGlobalConfig().CurrentConfig.TimeDependenceDecimalPrecision) << (timeDependence * multiplier); 102 | return ss.str(); 103 | } 104 | 105 | static std::string 106 | GetJudgementText(Judgement& judgement, int score, int before, int after, int accuracy, float timeDependence, int maxScore, Direction wrongDirection) { 107 | auto& text = judgement.Text; 108 | 109 | text.set_beforeCut(std::to_string(before)); 110 | text.set_accuracy(std::to_string(accuracy)); 111 | text.set_afterCut(std::to_string(after)); 112 | text.set_score(std::to_string(score)); 113 | text.set_percent(std::to_string(round(100 * (float) score / maxScore))); 114 | text.set_timeDependency(TimeDependenceString(timeDependence)); 115 | text.set_beforeCutSegment(GetBestSegmentText(getGlobalConfig().CurrentConfig.BeforeCutAngleSegments, before)); 116 | text.set_accuracySegment(GetBestSegmentText(getGlobalConfig().CurrentConfig.AccuracySegments, accuracy)); 117 | text.set_afterCutSegment(GetBestSegmentText(getGlobalConfig().CurrentConfig.AfterCutAngleSegments, after)); 118 | text.set_timeDependencySegment(GetBestFloatSegmentText(getGlobalConfig().CurrentConfig.TimeDependenceSegments, timeDependence)); 119 | text.set_direction(GetDirectionText(wrongDirection)); 120 | 121 | return text.Join(); 122 | } 123 | 124 | static UnityEngine::Color GetJudgementColor(Judgement& judgement, std::vector& judgements, int score) { 125 | if (!judgement.Fade || !judgement.Fade.value()) 126 | return judgement.Color.Color; 127 | // get the lowest judgement with a higher threshold 128 | Judgement* best = nullptr; 129 | for (auto& judgement : judgements) { 130 | if (score < judgement.Threshold && (!best || judgement.Threshold < best->Threshold)) 131 | best = &judgement; 132 | } 133 | if (!best) 134 | return judgement.Color.Color; 135 | int lowerThreshold = judgement.Threshold; 136 | int higherThreshold = best->Threshold; 137 | float lerpDistance = ((float) score - lowerThreshold) / (higherThreshold - lowerThreshold); 138 | auto lowerColor = judgement.Color.Color; 139 | auto higherColor = best->Color.Color; 140 | return UnityEngine::Color( 141 | lowerColor.r + (higherColor.r - lowerColor.r) * lerpDistance, 142 | lowerColor.g + (higherColor.g - lowerColor.g) * lerpDistance, 143 | lowerColor.b + (higherColor.b - lowerColor.b) * lerpDistance, 144 | lowerColor.a + (higherColor.a - lowerColor.a) * lerpDistance 145 | ); 146 | } 147 | 148 | static void UpdateScoreEffect( 149 | GlobalNamespace::FlyingScoreEffect* flyingScoreEffect, 150 | int total, 151 | int before, 152 | int after, 153 | int accuracy, 154 | float timeDependence, 155 | ScoringType scoringType, 156 | Direction wrongDirection 157 | ) { 158 | std::string text; 159 | UnityEngine::Color color; 160 | 161 | int maxScore = GlobalNamespace::ScoreModel::GetNoteScoreDefinition(scoringType)->maxCutScore; 162 | 163 | if (scoringType == ScoringType::ChainLink || scoringType == ScoringType::ChainLinkArcHead) { 164 | auto&& judgement = 165 | getGlobalConfig().CurrentConfig.ChainLinkDisplay.value_or(GetBestJudgement(getGlobalConfig().CurrentConfig.Judgements, total)); 166 | 167 | text = GetJudgementText(judgement, total, before, after, accuracy, timeDependence, maxScore, wrongDirection); 168 | color = judgement.Color.Color; 169 | } else if (scoringType == ScoringType::ChainHead || scoringType == ScoringType::ChainHeadArcTail) { 170 | auto& judgementVector = getGlobalConfig().CurrentConfig.ChainHeadJudgements; 171 | auto& judgement = GetBestJudgement(judgementVector, total); 172 | 173 | text = GetJudgementText(judgement, total, before, after, accuracy, timeDependence, maxScore, wrongDirection); 174 | color = GetJudgementColor(judgement, judgementVector, total); 175 | } else { 176 | auto& judgementVector = getGlobalConfig().CurrentConfig.Judgements; 177 | auto& judgement = GetBestJudgement(judgementVector, total); 178 | 179 | text = GetJudgementText(judgement, total, before, after, accuracy, timeDependence, maxScore, wrongDirection); 180 | color = GetJudgementColor(judgement, judgementVector, total); 181 | } 182 | 183 | flyingScoreEffect->_text->text = text; 184 | flyingScoreEffect->_text->color = color; 185 | flyingScoreEffect->_color = color; 186 | } 187 | 188 | void Judge( 189 | GlobalNamespace::CutScoreBuffer* cutScoreBuffer, 190 | GlobalNamespace::FlyingScoreEffect* flyingScoreEffect, 191 | GlobalNamespace::NoteCutInfo const& noteCutInfo 192 | ) { 193 | if (!cutScoreBuffer) { 194 | logger.info("CutScoreBuffer is null"); 195 | return; 196 | } 197 | if (!flyingScoreEffect || !flyingScoreEffect->_text) { 198 | logger.info("FlyingScoreEffect is null"); 199 | return; 200 | } 201 | 202 | if (!cutScoreBuffer->isFinished && getGlobalConfig().HideUntilDone.GetValue()) { 203 | flyingScoreEffect->_text->text = ""; 204 | return; 205 | } 206 | 207 | // get scores for each part of the cut 208 | int before = cutScoreBuffer->beforeCutScore; 209 | int after = cutScoreBuffer->afterCutScore; 210 | int accuracy = cutScoreBuffer->centerDistanceCutScore; 211 | int total = cutScoreBuffer->cutScore; 212 | float timeDependence = std::abs(noteCutInfo.cutNormal.z); 213 | Direction wrongDirection = GetWrongDirection(noteCutInfo); 214 | 215 | ScoringType scoringType = noteCutInfo.noteData->scoringType; 216 | 217 | UpdateScoreEffect(flyingScoreEffect, total, before, after, accuracy, timeDependence, scoringType, wrongDirection); 218 | } 219 | 220 | static int wrongDirectionsCounter = 0; 221 | static int wrongColorsCounter = 0; 222 | static int bombsCounter = 0; 223 | static int missesCounter = 0; 224 | 225 | static std::random_device device; 226 | static std::default_random_engine rng(device()); 227 | 228 | static int Random(int min, int max) { 229 | return std::uniform_int_distribution(min, max)(rng); 230 | } 231 | 232 | template 233 | static T const& GetDisplay(std::vector const& displays, int& counter, bool randomize) { 234 | int idx = randomize ? std::uniform_int_distribution(0, displays.size())(rng) : (counter++ % displays.size()); 235 | return displays[idx]; 236 | } 237 | 238 | static std::pair&> GetBadCutList(GlobalNamespace::NoteCutInfo const& noteCutInfo) { 239 | if (noteCutInfo.noteData->colorType == GlobalNamespace::ColorType::None) 240 | return {bombsCounter, getGlobalConfig().CurrentConfig.Bombs}; 241 | if (!noteCutInfo.saberTypeOK) 242 | return {wrongColorsCounter, getGlobalConfig().CurrentConfig.WrongColors}; 243 | return {wrongDirectionsCounter, getGlobalConfig().CurrentConfig.WrongDirections}; 244 | } 245 | 246 | bool SpawnBadCut(GlobalNamespace::FlyingTextSpawner* spawner, GlobalNamespace::NoteCutInfo const& noteCutInfo) { 247 | auto [counter, vector] = GetBadCutList(noteCutInfo); 248 | if (vector.empty() || !spawner) 249 | return false; 250 | bool randomize = getGlobalConfig().CurrentConfig.RandomizeBadCutDisplays; 251 | auto const& display = GetDisplay(vector, counter, randomize); 252 | spawner->_color = display.Color.Color; 253 | spawner->SpawnText(noteCutInfo.cutPoint, noteCutInfo.worldRotation, noteCutInfo.inverseWorldRotation, display.Text); 254 | return true; 255 | } 256 | 257 | bool SpawnMiss(GlobalNamespace::FlyingTextSpawner* spawner, GlobalNamespace::NoteController* note, float z) { 258 | auto& config = getGlobalConfig().CurrentConfig; 259 | if (config.MissDisplays.empty() || !spawner) 260 | return false; 261 | auto const& display = GetDisplay(config.MissDisplays, missesCounter, config.RandomizeMissDisplays); 262 | spawner->_color = display.Color.Color; 263 | auto position = note->inverseWorldRotation * note->_noteTransform->position; 264 | position.z = z; 265 | spawner->SpawnText(position, note->worldRotation, note->inverseWorldRotation, display.Text); 266 | return true; 267 | } 268 | -------------------------------------------------------------------------------- /src/Main.cpp: -------------------------------------------------------------------------------- 1 | #include "Main.hpp" 2 | 3 | #include "Config.hpp" 4 | #include "GlobalNamespace/AudioTimeSyncController.hpp" 5 | #include "GlobalNamespace/BadNoteCutEffectSpawner.hpp" 6 | #include "GlobalNamespace/BeatmapObjectExecutionRating.hpp" 7 | #include "GlobalNamespace/EffectPoolsManualInstaller.hpp" 8 | #include "GlobalNamespace/FlyingScoreEffect.hpp" 9 | #include "GlobalNamespace/FlyingScoreSpawner.hpp" 10 | #include "GlobalNamespace/FlyingSpriteSpawner.hpp" 11 | #include "GlobalNamespace/IReadonlyCutScoreBuffer.hpp" 12 | #include "GlobalNamespace/MissedNoteEffectSpawner.hpp" 13 | #include "GlobalNamespace/NoteData.hpp" 14 | #include "Settings.hpp" 15 | #include "TMPro/TextMeshPro.hpp" 16 | #include "UnityEngine/AnimationCurve.hpp" 17 | #include "UnityEngine/SpriteRenderer.hpp" 18 | #include "Zenject/DiContainer.hpp" 19 | #include "beatsaber-hook/shared/utils/hooking.hpp" 20 | #include "bsml/shared/BSML.hpp" 21 | #include "custom-types/shared/register.hpp" 22 | #include "json/DefaultConfig.hpp" 23 | #include "metacore/shared/events.hpp" 24 | #include "metacore/shared/unity.hpp" 25 | 26 | static modloader::ModInfo modInfo = {MOD_ID, VERSION, 0}; 27 | 28 | static GlobalNamespace::FlyingTextSpawner* textSpawner; 29 | 30 | std::string ConfigsPath() { 31 | static std::string path = getDataDir(modInfo); 32 | return path; 33 | } 34 | 35 | static void SetDefaultConfig() { 36 | getGlobalConfig().SelectedConfig.SetValue(""); 37 | getGlobalConfig().CurrentConfig = defaultConfig; 38 | } 39 | 40 | void LoadCurrentConfig() { 41 | std::string selected = getGlobalConfig().SelectedConfig.GetValue(); 42 | if (!selected.empty() && !fileexists(selected)) { 43 | logger.warn("Could not find selected config! Using the default"); 44 | SetDefaultConfig(); 45 | return; 46 | } 47 | try { 48 | ReadFromFile(selected, getGlobalConfig().CurrentConfig); 49 | } catch (std::exception const& err) { 50 | logger.error("Could not load config file {}: {}", selected, err.what()); 51 | SetDefaultConfig(); 52 | } 53 | } 54 | 55 | // used for fixed position 56 | GlobalNamespace::FlyingScoreEffect* currentEffect = nullptr; 57 | // used for updating ratings 58 | std::unordered_map swingRatingMap = {}; 59 | 60 | static bool SkipJudge(GlobalNamespace::NoteCutInfo const& cutInfo) { 61 | using ScoringType = GlobalNamespace::NoteData::ScoringType; 62 | 63 | auto cutType = cutInfo.noteData->scoringType; 64 | if (cutType == ScoringType::ChainHead || cutType == ScoringType::ChainHeadArcTail) 65 | return !getGlobalConfig().CurrentConfig.HasChainHead(); 66 | if (cutType == ScoringType::ChainLink || cutType == ScoringType::ChainLinkArcHead) 67 | return !getGlobalConfig().CurrentConfig.HasChainLink(); 68 | return false; 69 | } 70 | 71 | MAKE_HOOK_MATCH( 72 | FlyingScoreEffect_InitAndPresent, 73 | &GlobalNamespace::FlyingScoreEffect::InitAndPresent, 74 | void, 75 | GlobalNamespace::FlyingScoreEffect* self, 76 | GlobalNamespace::IReadonlyCutScoreBuffer* cutScoreBuffer, 77 | float duration, 78 | UnityEngine::Vector3 targetPos, 79 | UnityEngine::Color color 80 | ) { 81 | bool enabled = getGlobalConfig().ModEnabled.GetValue(); 82 | 83 | if (enabled) { 84 | auto& config = getGlobalConfig().CurrentConfig; 85 | if (config.FixedPos) { 86 | targetPos = config.FixedPos.value(); 87 | self->transform->position = targetPos; 88 | if (!getGlobalConfig().HideUntilDone.GetValue()) { 89 | if (currentEffect) 90 | currentEffect->gameObject->active = false; 91 | currentEffect = self; 92 | } 93 | } else if (config.PosOffset) 94 | targetPos = UnityEngine::Vector3::op_Addition(targetPos, *config.PosOffset); 95 | } 96 | FlyingScoreEffect_InitAndPresent(self, cutScoreBuffer, duration, targetPos, color); 97 | 98 | if (enabled) { 99 | if (cutScoreBuffer == nullptr) { 100 | logger.error("CutScoreBuffer is null!"); 101 | return; 102 | } 103 | auto cast = il2cpp_utils::try_cast(cutScoreBuffer).value_or(nullptr); 104 | if (cast == nullptr) { 105 | logger.error("CutScoreBuffer is not GlobalNamespace::CutScoreBuffer!"); 106 | return; 107 | } 108 | if (SkipJudge(cast->noteCutInfo)) 109 | return; 110 | 111 | if (!cast->isFinished) 112 | swingRatingMap.insert({cast, self}); 113 | 114 | self->_maxCutDistanceScoreIndicator->enabled = false; 115 | self->_text->richText = true; 116 | self->_text->enableWordWrapping = false; 117 | self->_text->overflowMode = TMPro::TextOverflowModes::Overflow; 118 | 119 | Judge(cast, self, cast->noteCutInfo); 120 | } 121 | } 122 | 123 | MAKE_HOOK_MATCH( 124 | CutScoreBuffer_HandleSaberSwingRatingCounterDidChange, 125 | &GlobalNamespace::CutScoreBuffer::HandleSaberSwingRatingCounterDidChange, 126 | void, 127 | GlobalNamespace::CutScoreBuffer* self, 128 | GlobalNamespace::ISaberSwingRatingCounter* swingRatingCounter, 129 | float rating 130 | ) { 131 | CutScoreBuffer_HandleSaberSwingRatingCounterDidChange(self, swingRatingCounter, rating); 132 | 133 | if (getGlobalConfig().ModEnabled.GetValue()) { 134 | if (SkipJudge(self->noteCutInfo)) 135 | return; 136 | 137 | auto itr = swingRatingMap.find(self); 138 | if (itr == swingRatingMap.end()) 139 | return; 140 | auto flyingScoreEffect = itr->second; 141 | 142 | Judge(self, flyingScoreEffect, self->noteCutInfo); 143 | } 144 | } 145 | 146 | MAKE_HOOK_MATCH( 147 | CutScoreBuffer_HandleSaberSwingRatingCounterDidFinish, 148 | &GlobalNamespace::CutScoreBuffer::HandleSaberSwingRatingCounterDidFinish, 149 | void, 150 | GlobalNamespace::CutScoreBuffer* self, 151 | GlobalNamespace::ISaberSwingRatingCounter* swingRatingCounter 152 | ) { 153 | CutScoreBuffer_HandleSaberSwingRatingCounterDidFinish(self, swingRatingCounter); 154 | 155 | if (getGlobalConfig().ModEnabled.GetValue()) { 156 | if (SkipJudge(self->noteCutInfo)) 157 | return; 158 | 159 | auto itr = swingRatingMap.find(self); 160 | if (itr == swingRatingMap.end()) 161 | return; 162 | auto flyingScoreEffect = itr->second; 163 | swingRatingMap.erase(itr); 164 | 165 | Judge(self, flyingScoreEffect, self->noteCutInfo); 166 | 167 | if (getGlobalConfig().CurrentConfig.FixedPos && getGlobalConfig().HideUntilDone.GetValue()) { 168 | if (currentEffect) 169 | currentEffect->gameObject->active = false; 170 | currentEffect = flyingScoreEffect; 171 | } 172 | } 173 | } 174 | 175 | MAKE_HOOK_MATCH( 176 | FlyingScoreSpawner_HandleFlyingObjectEffectDidFinish, 177 | &GlobalNamespace::FlyingScoreSpawner::HandleFlyingObjectEffectDidFinish, 178 | void, 179 | GlobalNamespace::FlyingScoreSpawner* self, 180 | GlobalNamespace::FlyingObjectEffect* effect 181 | ) { 182 | if (currentEffect == (GlobalNamespace::FlyingScoreEffect*) effect) { 183 | currentEffect->gameObject->active = false; 184 | currentEffect = nullptr; 185 | } 186 | FlyingScoreSpawner_HandleFlyingObjectEffectDidFinish(self, effect); 187 | } 188 | 189 | MAKE_HOOK_MATCH( 190 | FlyingScoreEffect_ManualUpdate, &GlobalNamespace::FlyingScoreEffect::ManualUpdate, void, GlobalNamespace::FlyingScoreEffect* self, float t 191 | ) { 192 | FlyingScoreEffect_ManualUpdate(self, t); 193 | 194 | if (getGlobalConfig().ModEnabled.GetValue()) { 195 | self->_color.a = self->_fadeAnimationCurve->Evaluate(t); 196 | self->_text->color = self->_color; 197 | } 198 | } 199 | 200 | MAKE_HOOK_MATCH( 201 | EffectPoolsManualInstaller_ManualInstallBindings, 202 | &GlobalNamespace::EffectPoolsManualInstaller::ManualInstallBindings, 203 | void, 204 | GlobalNamespace::EffectPoolsManualInstaller* self, 205 | Zenject::DiContainer* container, 206 | bool shortBeatEffect 207 | ) { 208 | EffectPoolsManualInstaller_ManualInstallBindings(self, container, shortBeatEffect); 209 | 210 | // use zenject to populate the text effect pool 211 | textSpawner = container->InstantiateComponentOnNewGameObject("HSVFlyingTextSpawner"); 212 | textSpawner->_duration = 0.7; 213 | textSpawner->_xSpread = 2; 214 | textSpawner->_targetYPos = 1.3; 215 | textSpawner->_targetZPos = 14; 216 | textSpawner->_shake = false; 217 | textSpawner->_fontSize = 4.5; 218 | MetaCore::Engine::SetOnDestroy(textSpawner, []() { textSpawner = nullptr; }); 219 | logger.debug("created text spawner"); 220 | } 221 | 222 | MAKE_HOOK_MATCH( 223 | BadNoteCutEffectSpawner_HandleNoteWasCut, 224 | &GlobalNamespace::BadNoteCutEffectSpawner::HandleNoteWasCut, 225 | void, 226 | GlobalNamespace::BadNoteCutEffectSpawner* self, 227 | GlobalNamespace::NoteController* noteController, 228 | ByRef noteCutInfo 229 | ) { 230 | if (noteController->noteData->time + 0.5 < self->_audioTimeSyncController->songTime) 231 | return; 232 | if (!SpawnBadCut(textSpawner, noteCutInfo.heldRef)) 233 | BadNoteCutEffectSpawner_HandleNoteWasCut(self, noteController, noteCutInfo); 234 | } 235 | 236 | MAKE_HOOK_MATCH( 237 | MissedNoteEffectSpawner_HandleNoteWasMissed, 238 | &GlobalNamespace::MissedNoteEffectSpawner::HandleNoteWasMissed, 239 | void, 240 | GlobalNamespace::MissedNoteEffectSpawner* self, 241 | GlobalNamespace::NoteController* noteController 242 | ) { 243 | if (noteController->hidden || noteController->noteData->time + 0.5 < self->_audioTimeSyncController->songTime || 244 | noteController->noteData->colorType == GlobalNamespace::ColorType::None) 245 | return; 246 | if (!SpawnMiss(textSpawner, noteController, self->_spawnPosZ)) 247 | MissedNoteEffectSpawner_HandleNoteWasMissed(self, noteController); 248 | } 249 | 250 | extern "C" void setup(CModInfo* info) { 251 | *info = modInfo.to_c(); 252 | 253 | Paper::Logger::RegisterFileContextId(MOD_ID); 254 | 255 | getGlobalConfig().Init(modInfo); 256 | 257 | if (!direxists(ConfigsPath())) 258 | mkpath(ConfigsPath()); 259 | 260 | LoadCurrentConfig(); 261 | 262 | logger.info("Completed setup!"); 263 | } 264 | 265 | extern "C" void late_load() { 266 | il2cpp_functions::Init(); 267 | custom_types::Register::AutoRegister(); 268 | BSML::Init(); 269 | 270 | BSML::Register::RegisterSettingsMenu("Hit Score Visualizer"); 271 | BSML::Register::RegisterMainMenu("Hit Score Visualizer", "Hit Score Visualizer", ""); 272 | 273 | logger.info("Installing hooks..."); 274 | INSTALL_HOOK(logger, FlyingScoreEffect_InitAndPresent); 275 | INSTALL_HOOK(logger, CutScoreBuffer_HandleSaberSwingRatingCounterDidChange); 276 | INSTALL_HOOK(logger, CutScoreBuffer_HandleSaberSwingRatingCounterDidFinish); 277 | INSTALL_HOOK(logger, FlyingScoreSpawner_HandleFlyingObjectEffectDidFinish); 278 | INSTALL_HOOK(logger, FlyingScoreEffect_ManualUpdate); 279 | INSTALL_HOOK(logger, EffectPoolsManualInstaller_ManualInstallBindings); 280 | INSTALL_HOOK(logger, BadNoteCutEffectSpawner_HandleNoteWasCut); 281 | INSTALL_HOOK(logger, MissedNoteEffectSpawner_HandleNoteWasMissed); 282 | logger.info("Installed all hooks!"); 283 | } 284 | -------------------------------------------------------------------------------- /src/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include "Settings.hpp" 2 | 3 | #include "Config.hpp" 4 | #include "HMUI/Touchable.hpp" 5 | #include "Main.hpp" 6 | #include "UnityEngine/Resources.hpp" 7 | #include "bsml/shared/BSML-Lite.hpp" 8 | 9 | DEFINE_TYPE(HSV, CustomList); 10 | DEFINE_TYPE(HSV, SettingsViewController); 11 | 12 | using namespace HSV; 13 | 14 | void CustomList::ctor() { 15 | INVOKE_CTOR(); 16 | expandCell = false; 17 | reuseIdentifier = "HSVConfigListTableCell"; 18 | cellSize = 8; 19 | tableView = nullptr; 20 | } 21 | 22 | HMUI::TableCell* CustomList::CellForIdx(HMUI::TableView* tableView, int idx) { 23 | auto tableCell = (GlobalNamespace::SimpleTextTableCell*) tableView->DequeueReusableCellForIdentifier(reuseIdentifier).unsafePtr(); 24 | if (!tableCell) { 25 | auto simpleTextTableCellInstance = UnityEngine::Resources::FindObjectsOfTypeAll()->First([](auto x) { 26 | return x->name == std::string("SimpleTextTableCell"); 27 | }); 28 | tableCell = Instantiate(simpleTextTableCellInstance); 29 | tableCell->reuseIdentifier = reuseIdentifier; 30 | 31 | tableCell->_text->richText = true; 32 | tableCell->_text->enableWordWrapping = false; 33 | BSML::Lite::AddHoverHint(tableCell, ""); 34 | } 35 | 36 | tableCell->text = data[idx]; 37 | if (failures.contains(idx)) { 38 | tableCell->GetComponent()->text = failures[idx]; 39 | tableCell->interactable = false; 40 | } else { 41 | tableCell->GetComponent()->text = ""; 42 | tableCell->interactable = true; 43 | } 44 | tableCell->gameObject->active = true; 45 | return tableCell; 46 | } 47 | 48 | float CustomList::CellSize() { 49 | return cellSize; 50 | } 51 | 52 | int CustomList::NumberOfCells() { 53 | return data.size(); 54 | } 55 | 56 | int SettingsViewController::selectedIdx = -1; 57 | std::vector SettingsViewController::fullConfigPaths = {}; 58 | 59 | void SettingsViewController::ConfigSelected(int idx) { 60 | selectedIdx = idx; 61 | getGlobalConfig().SelectedConfig.SetValue(fullConfigPaths[idx]); 62 | LoadCurrentConfig(); 63 | selectedConfig->text = "Current Config: " + configList->data[idx]; 64 | } 65 | 66 | void SettingsViewController::RefreshConfigList() { 67 | auto& failureMap = configList->failures; 68 | auto& data = configList->data; 69 | failureMap.clear(); 70 | data = {"Default"}; 71 | fullConfigPaths = {""}; 72 | if (getGlobalConfig().SelectedConfig.GetValue() == "") 73 | selectedIdx = 0; 74 | 75 | Config config; 76 | for (auto& entry : std::filesystem::recursive_directory_iterator(ConfigsPath())) { 77 | std::string displayPath = entry.path().stem().string(); 78 | std::string fullPath = entry.path().string(); 79 | // test loading the config 80 | try { 81 | ReadFromFile(fullPath, config); 82 | } catch (std::exception const& err) { 83 | logger.error("Could not load config file {}: {}", fullPath, err.what()); 84 | data.emplace_back(fmt::format("{}", displayPath)); 85 | fullConfigPaths.emplace_back(fullPath); 86 | failureMap.insert({data.size() - 1, fmt::format("Error loading config: {}", err.what())}); 87 | continue; 88 | } 89 | data.emplace_back(displayPath); 90 | fullConfigPaths.emplace_back(fullPath); 91 | if (getGlobalConfig().SelectedConfig.GetValue() == fullPath) 92 | selectedIdx = data.size() - 1; 93 | } 94 | configList->tableView->ReloadData(); 95 | if (selectedIdx >= 0) { 96 | configList->tableView->SelectCellWithIdx(selectedIdx, false); 97 | configList->tableView->ScrollToCellWithIdx(selectedIdx, HMUI::TableView::ScrollPositionType::Beginning, false); 98 | } 99 | } 100 | 101 | void SettingsViewController::RefreshUI() { 102 | RefreshConfigList(); 103 | selectedConfig->text = "Current Config: " + configList->data[selectedIdx]; 104 | enabledToggle->toggle->isOn = getGlobalConfig().ModEnabled.GetValue(); 105 | hideToggle->toggle->isOn = getGlobalConfig().HideUntilDone.GetValue(); 106 | } 107 | 108 | void SettingsViewController::DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) { 109 | if (firstActivation) { 110 | gameObject->AddComponent(); 111 | auto container = BSML::Lite::CreateVerticalLayoutGroup(transform); 112 | 113 | auto textLayout = BSML::Lite::CreateVerticalLayoutGroup(container); 114 | textLayout->childForceExpandHeight = false; 115 | textLayout->childForceExpandWidth = false; 116 | textLayout->childControlHeight = true; 117 | textLayout->spacing = 1.5; 118 | 119 | enabledToggle = BSML::Lite::CreateToggle(textLayout, "Mod Enabled", getGlobalConfig().ModEnabled.GetValue(), [](bool enabled) { 120 | getGlobalConfig().ModEnabled.SetValue(enabled); 121 | }); 122 | BSML::Lite::AddHoverHint(enabledToggle, "Toggles whether the mod is active or not"); 123 | 124 | hideToggle = 125 | BSML::Lite::CreateToggle(textLayout, "Hide Until Calculation Finishes", getGlobalConfig().HideUntilDone.GetValue(), [](bool enabled) { 126 | getGlobalConfig().HideUntilDone.SetValue(enabled); 127 | }); 128 | BSML::Lite::AddHoverHint(enabledToggle, "With this enabled, the hit scores will not be displayed until the score has been finalized"); 129 | 130 | selectedConfig = BSML::Lite::CreateText(textLayout, ""); 131 | 132 | configList = BSML::Lite::CreateScrollableCustomSourceList(container, {50, 50}, [this](int idx) { ConfigSelected(idx); }); 133 | } 134 | RefreshUI(); 135 | } 136 | --------------------------------------------------------------------------------