diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index fc61573416..0000000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-github: ppy
-custom: https://osu.ppy.sh/home/support
diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
deleted file mode 100644
index a8a5d5e64b..0000000000
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: Bug report
-description: Report a very clearly broken issue.
-body:
- - type: markdown
- attributes:
- value: |
- # osu! bug report
-
- Important to note that your issue may have already been reported before. Please check:
- - Pinned issues, at the top of https://github.com/ppy/osu/issues.
- - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
- - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful.
-
- # ATTENTION LINUX USERS
-
- If you are having an issue and it is hardware related, **please open a [q&a discussion](https://github.com/ppy/osu/discussions/categories/q-a)** instead of an issue. There's a high chance your issue is due to your system configuration, and not our software.
-
- - type: dropdown
- attributes:
- label: Type
- options:
- - Crash to desktop
- - Game behaviour
- - Performance
- - Cosmetic
- - Other
- validations:
- required: true
- - type: textarea
- attributes:
- label: Bug description
- description: How did you find the bug? Any additional details that might help?
- validations:
- required: true
- - type: textarea
- attributes:
- label: Screenshots or videos
- description: Add screenshots or videos that show the bug here.
- placeholder: Drag and drop the screenshots/videos into this box.
- validations:
- required: false
- - type: input
- attributes:
- label: Version
- description: The version you encountered this bug on. This is shown at the end of the settings overlay.
- validations:
- required: true
- - type: markdown
- attributes:
- value: |
- ## Logs
-
- Attaching log files is required for **every** issue, regardless of whether you deem them required or not. See instructions below on how to find them.
-
- ### Desktop platforms
-
- If the game has not yet been closed since you found the bug:
- 1. Head on to game settings and click on "Export logs"
- 2. Click the notification to locate the file
- 3. Drag the generated `.zip` files into the github issue window
-
- 
-
- ### Mobile platforms
-
- The places to find the logs on mobile platforms are as follows:
- - *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app.
- - *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
-
- - type: textarea
- attributes:
- label: Logs
- placeholder: Drag and drop the log files into this box.
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index ec57232126..0000000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-blank_issues_enabled: false
-contact_links:
- - name: Help
- url: https://github.com/ppy/osu/discussions/categories/q-a
- about: osu! not working or performing as you'd expect? Not sure it's a bug? Check the Q&A section!
- - name: Suggestions or feature request
- url: https://github.com/ppy/osu/discussions/categories/ideas
- about: Got something you think should change or be added? Search for or start a new discussion!
- - name: osu!stable issues
- url: https://github.com/ppy/osu-stable-issues
- about: For osu!(stable) - ie. the current "live" game version, check out the dedicated repository. Note that this is for serious bug reports only, not tech support.
-
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 814fc81f51..0000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,46 +0,0 @@
-version: 2
-updates:
-- package-ecosystem: nuget
- directory: "/"
- schedule:
- interval: monthly
- time: "17:00"
- open-pull-requests-limit: 0 # disabled until https://github.com/dependabot/dependabot-core/issues/369 is resolved.
- ignore:
- - dependency-name: Microsoft.EntityFrameworkCore.Design
- versions:
- - "> 2.2.6"
- - dependency-name: Microsoft.EntityFrameworkCore.Sqlite
- versions:
- - "> 2.2.6"
- - dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core
- versions:
- - "> 2.2.6"
- - dependency-name: Microsoft.Extensions.DependencyInjection
- versions:
- - ">= 5.a, < 6"
- - dependency-name: NUnit3TestAdapter
- versions:
- - ">= 3.16.a, < 3.17"
- - dependency-name: Microsoft.NET.Test.Sdk
- versions:
- - 16.9.1
- - dependency-name: Microsoft.Extensions.DependencyInjection
- versions:
- - 3.1.11
- - 3.1.12
- - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson
- versions:
- - 3.1.11
- - dependency-name: Microsoft.NETCore.Targets
- versions:
- - 5.0.0
- - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack
- versions:
- - 5.0.2
- - dependency-name: NUnit
- versions:
- - 3.13.1
- - dependency-name: Microsoft.AspNetCore.SignalR.Client
- versions:
- - 3.1.11
diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml
deleted file mode 100644
index 2f1b2cf893..0000000000
--- a/.github/workflows/_diffcalc_processor.yml
+++ /dev/null
@@ -1,228 +0,0 @@
-name: "🔒diffcalc (do not use)"
-
-on:
- workflow_call:
- inputs:
- id:
- type: string
- head-sha:
- type: string
- pr-url:
- type: string
- pr-text:
- type: string
- dispatch-inputs:
- type: string
- outputs:
- target:
- description: The comparison target.
- value: ${{ jobs.generator.outputs.target }}
- sheet:
- description: The comparison spreadsheet.
- value: ${{ jobs.generator.outputs.sheet }}
- secrets:
- DIFFCALC_GOOGLE_CREDENTIALS:
- required: true
-
-env:
- GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }}
- GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env
-
-defaults:
- run:
- shell: bash -euo pipefail {0}
-
-jobs:
- generator:
- name: Run
- runs-on: self-hosted
- timeout-minutes: 1440
-
- outputs:
- target: ${{ steps.run.outputs.target }}
- sheet: ${{ steps.run.outputs.sheet }}
-
- steps:
- - name: Checkout diffcalc-sheet-generator
- uses: actions/checkout@v4
- with:
- path: ${{ inputs.id }}
- repository: 'smoogipoo/diffcalc-sheet-generator'
-
- - name: Add base environment
- env:
- GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json
- VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }}
- run: |
- # Required by diffcalc-sheet-generator
- cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}"
-
- # Add Google credentials
- echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}"
-
- # Add repository variables
- echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
- opt=$(jq -r '.key' <<< ${line})
- val=$(jq -r '.value' <<< ${line})
-
- if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
- optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
- sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}"
- fi
- done
-
- - name: Add HEAD environment
- run: |
- sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}"
-
- - name: Add pull-request environment
- if: ${{ inputs.pr-url != '' }}
- run: |
- sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}"
-
- - name: Add comment environment
- if: ${{ inputs.pr-text != '' }}
- env:
- PR_TEXT: ${{ inputs.pr-text }}
- run: |
- # Add comment environment
- echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do
- opt=$(echo "${line}" | cut -d '=' -f1)
- sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}"
- done
-
- - name: Add dispatch environment
- if: ${{ inputs.dispatch-inputs != '' }}
- env:
- DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }}
- run: |
- function get_input() {
- echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\""
- }
-
- osu_a=$(get_input 'osu-a')
- osu_b=$(get_input 'osu-b')
- ruleset=$(get_input 'ruleset')
- generators=$(get_input 'generators')
- difficulty_calculator_a=$(get_input 'difficulty-calculator-a')
- difficulty_calculator_b=$(get_input 'difficulty-calculator-b')
- score_processor_a=$(get_input 'score-processor-a')
- score_processor_b=$(get_input 'score-processor-b')
- converts=$(get_input 'converts')
- ranked_only=$(get_input 'ranked-only')
-
- sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}"
- sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}"
- sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}"
-
- if [[ "${osu_a}" != 'latest' ]]; then
- sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${difficulty_calculator_a}" != 'latest' ]]; then
- sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${difficulty_calculator_b}" != 'latest' ]]; then
- sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${score_processor_a}" != 'latest' ]]; then
- sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${score_processor_b}" != 'latest' ]]; then
- sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${converts}" == 'true' ]]; then
- sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}"
- else
- sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}"
- fi
-
- if [[ "${ranked_only}" == 'true' ]]; then
- sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}"
- else
- sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}"
- fi
-
- - name: Query latest scores
- id: query-scores
- run: |
- ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-)
- performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
-
- echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
- echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
- echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
-
- - name: Restore score cache
- id: restore-score-cache
- uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- with:
- path: ${{ steps.query-scores.outputs.DATA_PKG }}
- key: ${{ steps.query-scores.outputs.DATA_NAME }}
-
- - name: Download scores
- if: steps.restore-score-cache.outputs.cache-hit != 'true'
- run: |
- wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}"
-
- - name: Extract scores
- run: |
- tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}"
- rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}"
- mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}"
-
- - name: Query latest beatmaps
- id: query-beatmaps
- run: |
- beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
-
- echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
- echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
- echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
-
- - name: Restore beatmap cache
- id: restore-beatmap-cache
- uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- with:
- path: ${{ steps.query-beatmaps.outputs.DATA_PKG }}
- key: ${{ steps.query-beatmaps.outputs.DATA_NAME }}
-
- - name: Download beatmap
- if: steps.restore-beatmap-cache.outputs.cache-hit != 'true'
- run: |
- wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}"
-
- - name: Extract beatmap
- run: |
- tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}"
- rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
- mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
-
- - name: Run
- id: run
- run: |
- # Add the GitHub token. This needs to be done here because it's unique per-job.
- sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}"
-
- cd "${{ env.GENERATOR_DIR }}"
-
- docker compose up --build --detach
- docker compose logs --follow &
- docker compose wait generator
-
- link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
- target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
-
- echo "target=${target}" >> "${GITHUB_OUTPUT}"
- echo "sheet=${link}" >> "${GITHUB_OUTPUT}"
-
- - name: Shutdown
- if: ${{ always() }}
- run: |
- cd "${{ env.GENERATOR_DIR }}"
- docker compose down --volumes
- rm -rf "${{ env.GENERATOR_DIR }}"
diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
new file mode 100644
index 0000000000..9d2592f23b
--- /dev/null
+++ b/.github/workflows/auto-release.yml
@@ -0,0 +1,312 @@
+name: Ez2Lazer Auto Release (manual build + package + release)
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: '发布使用的 tag(格式 YYYY-M-D,年为四位,月禁止前导0 如 1-12,日为1-31,例如 2026-1-14),必填。工作流会在你选择的分支最新提交上创建该 tag(如果远程不存在)。'
+ required: true
+ branch:
+ description: '主仓要构建的分支(默认 locmain)'
+ required: true
+ default: 'locmain'
+ framework_branch:
+ description: '可选:用于 checkout osu-framework 的分支(留空则使用上面的 branch)'
+ required: false
+ default: ''
+ resources_branch:
+ description: '可选:用于 checkout osu-resources 的分支(留空则使用上面的 branch)'
+ required: false
+ default: ''
+ create_zip:
+ description: '是否在 workflow 中创建 zip 包(true/false)。默认 false'
+ required: false
+ default: 'false'
+ upload_assets:
+ description: '是否在 workflow 完成后创建 GitHub Release 并上传 zip(true/false)。默认 false'
+ required: false
+ default: 'false'
+
+jobs:
+ build-and-package:
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout main repo into osu/
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ path: osu
+ ref: refs/heads/${{ github.event.inputs.branch }}
+
+ - name: Checkout osu-framework (deps) as sibling
+ uses: actions/checkout@v4
+ with:
+ repository: SK-la/osu-framework
+ path: osu-framework
+ # Use framework_branch if provided, otherwise fall back to the main `branch` input
+ ref: refs/heads/${{ github.event.inputs.framework_branch || github.event.inputs.branch }}
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Validate osu-framework checkout (Windows)
+ shell: pwsh
+ run: |
+ if (-not (Test-Path 'osu-framework\.git')) {
+ Write-Output 'actions/checkout did not populate osu-framework; attempting HTTPS clone fallback'
+ git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-framework.git osu-framework
+ Push-Location osu-framework
+ $FrameworkBranch = '${{ github.event.inputs.framework_branch || github.event.inputs.branch }}'
+ git checkout $FrameworkBranch || Write-Output "checkout $FrameworkBranch failed, continuing"
+ Pop-Location
+ } else {
+ Write-Output 'osu-framework already present'
+ }
+
+ - name: Checkout osu-resources (resources) as sibling
+ uses: actions/checkout@v4
+ with:
+ repository: SK-la/osu-resources
+ path: osu-resources
+ ref: refs/heads/${{ github.event.inputs.resources_branch || github.event.inputs.branch }}
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Validate osu-resources checkout (Windows)
+ shell: pwsh
+ run: |
+ if (-not (Test-Path 'osu-resources\.git')) {
+ Write-Output 'actions/checkout did not populate osu-resources; attempting HTTPS clone fallback'
+ git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-resources.git osu-resources
+ Push-Location osu-resources
+ $ResourcesBranch = '${{ github.event.inputs.resources_branch || github.event.inputs.branch }}'
+ git checkout $ResourcesBranch || Write-Output "checkout $ResourcesBranch failed, continuing"
+ Pop-Location
+ } else {
+ Write-Output 'osu-resources already present'
+ }
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Cache NuGet packages (Windows)
+ if: runner.os == 'Windows'
+ uses: actions/cache@v4
+ with:
+ path: C:\\Users\\runneradmin\\.nuget\\packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props','**/*.csproj','**/packages.lock.json') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ - name: Cache MSBuild outputs (obj/bin)
+ if: runner.os == 'Windows'
+ uses: actions/cache@v4
+ with:
+ path: |
+ **/obj
+ **/bin
+ key: msbuild-${{ runner.os }}-${{ hashFiles('**/*.csproj','**/Directory.Packages.props') }}
+ restore-keys: |
+ msbuild-${{ runner.os }}-
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Dotnet restore (desktop project)
+ working-directory: osu
+ shell: pwsh
+ run: |
+ dotnet --info
+ dotnet restore ./osu.Desktop/osu.Desktop.csproj --packages $env:USERPROFILE\\.nuget\\packages
+
+ - name: Dotnet build (desktop project)
+ working-directory: osu
+ shell: pwsh
+ run: |
+ dotnet build ./osu.Desktop/osu.Desktop.csproj -c Release -p:GenerateFullPaths=true --no-restore --verbosity minimal
+
+ - name: Prepare release tag and environment
+ shell: pwsh
+ id: export
+ run: |
+ $inputTag = '${{ github.event.inputs.tag }}'
+ if (-not $inputTag) { Write-Error 'Input tag is required and must follow YYYY-M-D format'; exit 1 }
+ # validate strict: 4-digit year, month 1-12 without leading zero, day 1-31 without leading zero
+ if ($inputTag -notmatch '^[0-9]{4}-(?:[1-9]|1[0-2])-(?:[1-9]|[12][0-9]|3[01])$') { Write-Error 'Tag must match YYYY-M-D with no leading zero in month (e.g. 2026-1-14)'; exit 1 }
+ Write-Output "RELEASE_TAG=$inputTag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ # export branch for clarity (branch chosen when triggering the workflow)
+ Write-Output "TARGET_BRANCH=${{ github.event.inputs.branch }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Run publish script (pack & create release)
+ working-directory: osu
+ shell: pwsh
+ env:
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ PYTHONUTF8: '1'
+ PYTHONIOENCODING: 'utf-8'
+ run: |
+ # Use the RELEASE_TAG prepared by prior step; fail if not set to avoid fallback behavior
+ if (-not $env:RELEASE_TAG) { Write-Error 'RELEASE_TAG is not set; aborting to avoid fallback tag generation' ; exit 1 }
+ $tag = $env:RELEASE_TAG
+ Write-Output "Using TAG=$tag"
+ # Explicitly pass project, workdir, and outroot so artifacts land under osu/artifacts
+ $project = Join-Path (Get-Location) 'osu.Desktop\osu.Desktop.csproj'
+ $workdir = (Get-Location).Path
+ # outroot should be the working directory; publish.py will append /artifacts
+ $outroot = $workdir
+ $create_zip = '${{ github.event.inputs.create_zip }}'
+ if ($create_zip -eq 'true') {
+ python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag"
+ } else {
+ Write-Output 'create_zip is false -> running publish.py with --no-zip to avoid creating zip files'
+ python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag" --no-zip
+ }
+
+ - name: Debug publish environment (list files + --help)
+ working-directory: osu
+ shell: pwsh
+ continue-on-error: true
+ run: |
+ Write-Output "PWD: $(Get-Location)"
+ Write-Output "Listing osu/ (first 200 entries)"
+ Get-ChildItem -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 200 | ForEach-Object { Write-Output $_.FullName }
+ Write-Output "Python version:"
+ python --version || Write-Output 'python --version failed'
+ Write-Output "Dotnet info:"
+ dotnet --info || Write-Output 'dotnet --info failed'
+ Write-Output "Show the first 200 lines of publish.py"
+ Get-Content publish.py -TotalCount 200 -ErrorAction SilentlyContinue || Write-Output 'cat publish.py failed'
+ Write-Output "Running publish.py --help"
+ python ./publish.py --help || Write-Output 'publish.py --help failed'
+
+ - name: List artifacts directories for diagnostics
+ shell: pwsh
+ run: |
+ Write-Output 'Listing $GITHUB_WORKSPACE/artifacts:'
+ if (Test-Path "$env:GITHUB_WORKSPACE\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No root artifacts dir' }
+ Write-Output 'Listing $GITHUB_WORKSPACE/osu/artifacts:'
+ if (Test-Path "$env:GITHUB_WORKSPACE\osu\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\osu\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No osu artifacts dir' }
+
+ - name: Find produced zip artifact
+ if: ${{ github.event.inputs.create_zip == 'true' }}
+ shell: pwsh
+ id: list_zips
+ run: |
+ Write-Output "Searching for zip files under $env:GITHUB_WORKSPACE and $env:GITHUB_WORKSPACE\osu\artifacts"
+ $repoRoot = $env:GITHUB_WORKSPACE
+ $paths = @()
+ $paths += $repoRoot
+ $paths += (Join-Path $repoRoot 'osu\artifacts')
+ $found = $null
+ foreach ($p in $paths) {
+ if (Test-Path $p) {
+ Write-Output "Searching in: $p"
+ $z = Get-ChildItem -Path $p -Filter *.zip -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | ForEach-Object { $_.FullName }
+ if ($z -and $z.Count -gt 0) { $found = $z; break }
+ }
+ }
+ if ($found) {
+ Write-Output "Found zip(s): $found"
+ $first = $found[0]
+ $name = Split-Path -Path $first -Leaf
+ Write-Output "zip_path=$first" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
+ Write-Output "zip_name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
+ } else {
+ Write-Output "No zip files found in searched locations."
+ exit 1
+ }
+
+ - name: Normalize artifact names to fixed asset names
+ if: ${{ github.event.inputs.create_zip == 'true' }}
+ id: prepare_assets
+ shell: pwsh
+ run: |
+ $ws = $env:GITHUB_WORKSPACE
+ $artifacts_dir = Join-Path $ws 'artifacts'
+ if (-not (Test-Path $artifacts_dir)) { New-Item -ItemType Directory -Path $artifacts_dir | Out-Null }
+
+ $release_pattern = 'Ez2Lazer_release_x64*.zip'
+ $debug_pattern = 'Ez2Lazer_debug_x64*.zip'
+
+ function find-first([string]$base, [string]$pattern) {
+ $f = Get-ChildItem -Path $base -Filter $pattern -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | Select-Object -First 1
+ return $f
+ }
+
+ $rel = find-first $ws $release_pattern
+ if (-not $rel) { $rel = find-first (Join-Path $ws 'osu\artifacts') $release_pattern }
+ $dbg = find-first $ws $debug_pattern
+ if (-not $dbg) { $dbg = find-first (Join-Path $ws 'osu\artifacts') $debug_pattern }
+
+ $out_rel = ''
+ $out_dbg = ''
+ if ($rel) {
+ $out_rel = Join-Path $artifacts_dir 'Ez2Lazer_release_x64.zip'
+ Copy-Item -Path $rel.FullName -Destination $out_rel -Force
+ Write-Output "Prepared release asset: $out_rel"
+ } else { Write-Output 'Release zip not found' }
+
+ if ($dbg) {
+ $out_dbg = Join-Path $artifacts_dir 'Ez2Lazer_debug_x64.zip'
+ Copy-Item -Path $dbg.FullName -Destination $out_dbg -Force
+ Write-Output "Prepared debug asset: $out_dbg"
+ } else { Write-Output 'Debug zip not found' }
+
+ Write-Output "release_asset_path=$out_rel" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
+ Write-Output "debug_asset_path=$out_dbg" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
+
+ - name: Check remote tag existence and create if missing
+ shell: pwsh
+ run: |
+ $tag = '${{ env.RELEASE_TAG }}'
+ Write-Output "Checking remote for tag $tag..."
+ $found = git ls-remote --tags https://github.com/${{ github.repository }}.git refs/tags/$tag | Select-String -Pattern $tag
+ if ($found) { Write-Output "Tag $tag exists remotely"; exit 0 }
+ Write-Output "Tag $tag not found remotely - creating on branch tip"
+ # determine commit to tag: use the branch selected when triggering the workflow
+ $targetBranch = '${{ github.event.inputs.branch }}'
+ Write-Output "Resolving latest commit of branch $targetBranch"
+ $commitLine = git ls-remote https://github.com/${{ github.repository }}.git refs/heads/$targetBranch
+ if (-not $commitLine) { Write-Error "Could not resolve branch $targetBranch"; exit 1 }
+ $commit = $commitLine.Split()[0]
+ Write-Output "Tagging commit $commit"
+ git -C osu config user.name "github-actions[bot]"
+ git -C osu config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git -C osu tag $tag $commit
+ git -C osu push https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git refs/tags/$tag
+
+ - name: Create or get GitHub Release (create tag if needed)
+ if: ${{ github.event.inputs.upload_assets == 'true' }}
+ id: create_release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ env.RELEASE_TAG }}
+ name: ${{ env.RELEASE_TAG }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload release asset (release)
+ if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.release_asset_path != '' }}
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: ${{ steps.prepare_assets.outputs.release_asset_path }}
+ asset_name: Ez2Lazer_release_x64.zip
+ asset_content_type: application/zip
+
+ - name: Upload release asset (debug)
+ if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.debug_asset_path != '' }}
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: ${{ steps.prepare_assets.outputs.debug_asset_path }}
+ asset_name: Ez2Lazer_debug_x64.zip
+ asset_content_type: application/zip
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index 7dfe3d11c2..0000000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,154 +0,0 @@
-on: [push, pull_request]
-name: Continuous Integration
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read # to fetch code (actions/checkout)
-
-jobs:
- inspect-code:
- name: Code Quality
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Restore Tools
- run: dotnet tool restore
-
- - name: Restore Packages
- run: dotnet restore osu.Desktop.slnf
-
- - name: Restore inspectcode cache
- uses: actions/cache@v4
- with:
- path: ${{ github.workspace }}/inspectcode
- key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }}
-
- - name: Dotnet code style
- run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true
-
- - name: CodeFileSanity
- run: |
- # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround.
- # FIXME: Suppress warnings from templates project
- exit_code=0
- while read -r line; do
- if [[ ! -z "$line" ]]; then
- echo "::error::$line"
- exit_code=1
- fi
- done <<< $(dotnet codefilesanity)
- exit $exit_code
-
- - name: InspectCode
- run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
-
- - name: NVika
- run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors
-
- test:
- name: Test
- runs-on: ${{matrix.os.fullname}}
- env:
- OSU_EXECUTION_MODE: ${{matrix.threadingMode}}
- strategy:
- fail-fast: false
- matrix:
- os:
- - { prettyname: Windows, fullname: windows-latest }
- # macOS runner performance has gotten unbearably slow so let's turn them off temporarily.
- # - { prettyname: macOS, fullname: macos-latest }
- - { prettyname: Linux, fullname: ubuntu-latest }
- threadingMode: ['SingleThread', 'MultiThreaded']
- timeout-minutes: 120
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Compile
- run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
-
- - name: Test
- run: >
- dotnet test
- osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
- osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
- osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
- osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
- osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
- osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
- Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
- --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
- --
- NUnit.ConsoleOut=0
-
- # Attempt to upload results even if test fails.
- # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- - name: Upload Test Results
- uses: actions/upload-artifact@v4
- if: ${{ always() }}
- with:
- name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
- path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx
-
- build-only-android:
- name: Build only (Android)
- runs-on: windows-latest
- timeout-minutes: 60
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup JDK 11
- uses: actions/setup-java@v4
- with:
- distribution: microsoft
- java-version: 11
-
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Install .NET workloads
- run: dotnet workload install android
-
- - name: Compile
- run: dotnet build -c Debug osu.Android.slnf
-
- build-only-ios:
- name: Build only (iOS)
- runs-on: macos-15
- timeout-minutes: 60
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Install .NET Workloads
- run: dotnet workload install ios
-
- # https://github.com/dotnet/macios/issues/19157
- # https://github.com/actions/runner-images/issues/12758
- - name: Use Xcode 16.4
- run: sudo xcode-select -switch /Applications/Xcode_16.4.app
-
- - name: Build
- run: dotnet build -c Debug osu.iOS.slnf
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
deleted file mode 100644
index 1a921b21ae..0000000000
--- a/.github/workflows/deploy.yml
+++ /dev/null
@@ -1,87 +0,0 @@
-name: Pack and nuget
-
-on:
- push:
- tags:
- - '*'
-
-jobs:
- notify_pending_production_deploy:
- runs-on: ubuntu-latest
- steps:
- - name: Submit pending deployment notification
- run: |
- export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME"
- export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID"
- export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME:
- [View Workflow Run]($URL)"
- export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID"
-
- BODY="$(jq --null-input '{
- "embeds": [
- {
- "title": env.TITLE,
- "color": 15098112,
- "description": env.DESCRIPTION,
- "url": env.URL,
- "author": {
- "name": env.GITHUB_ACTOR,
- "icon_url": env.ACTOR_ICON
- }
- }
- ]
- }')"
-
- curl \
- -H "Content-Type: application/json" \
- -d "$BODY" \
- "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}"
-
- pack:
- name: Pack
- runs-on: ubuntu-latest
- environment: production
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set artifacts directory
- id: artifactsPath
- run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts"
-
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Pack
- run: |
- # Replace project references in templates with package reference, because they're included as source files.
- dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj
- dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
- dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj
- dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
-
- dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
- dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
- dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
- dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
-
- # Pack
- dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
-
- - name: Upload artifacts
- uses: actions/upload-artifact@v4
- with:
- name: osu
- path: |
- ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg
- ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg
-
- - name: Publish packages to nuget.org
- run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml
deleted file mode 100644
index 8461208a2e..0000000000
--- a/.github/workflows/diffcalc.yml
+++ /dev/null
@@ -1,196 +0,0 @@
-# ## Description
-#
-# Uses [diffcalc-sheet-generator](https://github.com/smoogipoo/diffcalc-sheet-generator) to run two builds of osu and generate an SR/PP/Score comparison spreadsheet.
-#
-# ## Requirements
-#
-# Self-hosted runner with installed:
-# - `docker >= 20.10.16`
-# - `docker-compose >= 2.5.1`
-# - `lbzip2`
-# - `jq`
-#
-# ## Usage
-#
-# The workflow can be run in two ways:
-# 1. Via workflow dispatch.
-# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`.
-# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable).
-# Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator.
-#
-# ## Google Service Account
-#
-# Spreadsheets are uploaded to a Google Service Account, and exposed with read-only permissions to the wider audience.
-#
-# 1. Create a project at https://console.cloud.google.com
-# 2. Enable the `Google Sheets` and `Google Drive` APIs.
-# 3. Create a Service Account
-# 4. Generate a key in the JSON format.
-# 5. Encode the key as base64 and store as an **actions secret** with name **`DIFFCALC_GOOGLE_CREDENTIALS`**
-#
-# ## Environment variables
-#
-# The default environment may be configured via **actions variables**.
-#
-# Refer to [the sample environment](https://github.com/smoogipoo/diffcalc-sheet-generator/blob/master/.env.sample), and prefix each variable with `DIFFCALC_` (e.g. `DIFFCALC_THREADS`, `DIFFCALC_INNODB_BUFFER_SIZE`, etc...).
-
-name: Run difficulty calculation comparison
-
-run-name: "${{ github.event_name == 'workflow_dispatch' && format('Manual run: {0}', inputs.osu-b) || 'Automatic comment trigger' }}"
-
-on:
- issue_comment:
- types: [ created ]
- workflow_dispatch:
- inputs:
- osu-b:
- description: "The target build of ppy/osu"
- type: string
- required: true
- ruleset:
- description: "The ruleset to process"
- type: choice
- required: true
- options:
- - osu
- - taiko
- - catch
- - mania
- converts:
- description: "Include converted beatmaps"
- type: boolean
- required: false
- default: true
- ranked-only:
- description: "Only ranked beatmaps"
- type: boolean
- required: false
- default: true
- generators:
- description: "Comma-separated list of generators (available: [sr, pp, score])"
- type: string
- required: false
- default: 'pp,sr'
- osu-a:
- description: "The source build of ppy/osu"
- type: string
- required: false
- default: 'latest'
- difficulty-calculator-a:
- description: "The source build of ppy/osu-difficulty-calculator"
- type: string
- required: false
- default: 'latest'
- difficulty-calculator-b:
- description: "The target build of ppy/osu-difficulty-calculator"
- type: string
- required: false
- default: 'latest'
- score-processor-a:
- description: "The source build of ppy/osu-queue-score-statistics"
- type: string
- required: false
- default: 'latest'
- score-processor-b:
- description: "The target build of ppy/osu-queue-score-statistics"
- type: string
- required: false
- default: 'latest'
-
-permissions:
- pull-requests: write
-
-env:
- EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
-
-defaults:
- run:
- shell: bash -euo pipefail {0}
-
-jobs:
- check-permissions:
- name: Check permissions
- runs-on: ubuntu-latest
- if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
- steps:
- - name: Check permissions
- run: |
- ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders)
- for i in "${ALLOWED_USERS[@]}"; do
- if [[ "${{ github.actor }}" == "$i" ]]; then
- exit 0
- fi
- done
- exit 1
-
- run-diffcalc:
- name: Run spreadsheet generator
- needs: check-permissions
- uses: ./.github/workflows/_diffcalc_processor.yml
- with:
- # Can't reference env... Why GitHub, WHY?
- id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
- head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }}
- pr-url: ${{ github.event.issue.pull_request.html_url || '' }}
- pr-text: ${{ github.event.comment.body || '' }}
- dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }}
- secrets:
- DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}
-
- create-comment:
- name: Create PR comment
- needs: check-permissions
- runs-on: ubuntu-latest
- if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
- steps:
- - name: Create comment
- uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
- with:
- comment_tag: ${{ env.EXECUTION_ID }}
- message: |
- Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
-
- *This comment will update on completion*
-
- output-cli:
- name: Info
- needs: run-diffcalc
- runs-on: ubuntu-latest
- steps:
- - name: Output info
- run: |
- echo "Target: ${{ needs.run-diffcalc.outputs.target }}"
- echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}"
-
- update-comment:
- name: Update PR comment
- needs: [ create-comment, run-diffcalc ]
- runs-on: ubuntu-latest
- if: ${{ always() && needs.create-comment.result == 'success' }}
- steps:
- - name: Update comment on success
- if: ${{ needs.run-diffcalc.result == 'success' }}
- uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
- with:
- comment_tag: ${{ env.EXECUTION_ID }}
- mode: recreate
- message: |
- Target: ${{ needs.run-diffcalc.outputs.target }}
- Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}
-
- - name: Update comment on failure
- if: ${{ needs.run-diffcalc.result == 'failure' }}
- uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
- with:
- comment_tag: ${{ env.EXECUTION_ID }}
- mode: recreate
- message: |
- Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-
- - name: Update comment on cancellation
- if: ${{ needs.run-diffcalc.result == 'cancelled' }}
- uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
- with:
- comment_tag: ${{ env.EXECUTION_ID }}
- mode: delete
- message: '.' # Appears to be required by this action for non-error status code.
diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml
new file mode 100644
index 0000000000..9feb9846cf
--- /dev/null
+++ b/.github/workflows/qodana_code_quality.yml
@@ -0,0 +1,28 @@
+name: Qodana
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches: # Specify your branches here
+ - locmain # The 'locmain' branch
+ - 'releases/*' # The release branches
+
+jobs:
+ qodana:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ checks: write
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
+ fetch-depth: 0 # a full history is required for pull request analysis
+ - name: 'Qodana Scan'
+ uses: JetBrains/qodana-action@v2025.2
+ with:
+ pr-mode: false
+ env:
+ QODANA_TOKEN: ${{ secrets.QODANA_TOKEN_1172857956 }}
+ QODANA_ENDPOINT: 'https://qodana.cloud'
diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml
deleted file mode 100644
index 14f0208fc8..0000000000
--- a/.github/workflows/report-nunit.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-# This is a workaround to allow PRs to report their coverage. This will run inside the base repository.
-# See:
-# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories
-# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token
-name: Annotate CI run with test results
-on:
- workflow_run:
- workflows: [ "Continuous Integration" ]
- types:
- - completed
-
-permissions:
- contents: read
- actions: read
- checks: write
-
-jobs:
- annotate:
- name: Annotate CI run with test results
- runs-on: ubuntu-latest
- if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
- timeout-minutes: 5
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- repository: ${{ github.event.workflow_run.repository.full_name }}
- ref: ${{ github.event.workflow_run.head_sha }}
-
- - name: Download results
- uses: actions/download-artifact@v4
- with:
- pattern: osu-test-results-*
- merge-multiple: true
- run-id: ${{ github.event.workflow_run.id }}
- github-token: ${{ github.token }}
-
- - name: Annotate CI run with test results
- uses: dorny/test-reporter@v1.8.0
- with:
- name: Results
- path: "*.trx"
- reporter: dotnet-trx
- list-suites: 'failed'
- list-tests: 'failed'
diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml
deleted file mode 100644
index be104d0fd3..0000000000
--- a/.github/workflows/sentry-release.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Add Release to Sentry
-
-on:
- push:
- tags:
- - '*'
-
-permissions:
- contents: read # to fetch code (actions/checkout)
-
-jobs:
- sentry_release:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Create Sentry release
- uses: getsentry/action-release@v1
- env:
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- SENTRY_ORG: ppy
- SENTRY_PROJECT: osu
- SENTRY_URL: https://sentry.ppy.sh/
- with:
- environment: production
- version: osu@${{ github.ref_name }}
diff --git a/.github/workflows/update-web-mod-definitions.yml b/.github/workflows/update-web-mod-definitions.yml
deleted file mode 100644
index b19f03ad7d..0000000000
--- a/.github/workflows/update-web-mod-definitions.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-name: Update osu-web mod definitions
-on:
- push:
- tags:
- - '*'
-
-permissions:
- contents: read # to fetch code (actions/checkout)
-
-jobs:
- update-mod-definitions:
- name: Update osu-web mod definitions
- runs-on: ubuntu-latest
- steps:
- - name: Install .NET 8.0.x
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "8.0.x"
-
- - name: Checkout ppy/osu
- uses: actions/checkout@v4
- with:
- path: osu
-
- - name: Checkout ppy/osu-tools
- uses: actions/checkout@v4
- with:
- repository: ppy/osu-tools
- path: osu-tools
-
- - name: Checkout ppy/osu-web
- uses: actions/checkout@v4
- with:
- repository: ppy/osu-web
- path: osu-web
-
- - name: Setup local game checkout for tools
- run: ./UseLocalOsu.sh
- working-directory: ./osu-tools
-
- - name: Regenerate mod definitions
- run: dotnet run --project PerformanceCalculator -- mods > ../osu-web/database/mods.json
- working-directory: ./osu-tools
-
- - name: Create pull request with changes
- uses: peter-evans/create-pull-request@v6
- with:
- title: Update mod definitions
- body: "This PR has been auto-generated to update the mod definitions to match ppy/osu@${{ github.ref_name }}."
- branch: update-mod-definitions
- commit-message: Update mod definitions
- path: osu-web
- token: ${{ secrets.OSU_WEB_PULL_REQUEST_PAT }}
diff --git a/.gitignore b/.gitignore
index 1fec94d82b..983916800b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -344,3 +344,8 @@ FodyWeavers.xsd
.idea/.idea.osu.Desktop/.idea/misc.xml
.idea/.idea.osu.Android/.idea/deploymentTargetDropDown.xml
+
+# MacOS literally shits anywhere lmao
+*.DS_Store
+
+.github/
\ No newline at end of file
diff --git a/.idea/.idea.osu.Desktop/.idea/vcs.xml b/.idea/.idea.osu.Desktop/.idea/vcs.xml
index 3de04b744c..a3eeefe61a 100644
--- a/.idea/.idea.osu.Desktop/.idea/vcs.xml
+++ b/.idea/.idea.osu.Desktop/.idea/vcs.xml
@@ -12,5 +12,7 @@
+
+
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7c5225cff7..60e31fc869 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -7,7 +7,7 @@
"request": "launch",
"program": "dotnet",
"args": [
- "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll"
+ "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build osu! (Debug)",
@@ -19,7 +19,7 @@
"request": "launch",
"program": "dotnet",
"args": [
- "${workspaceRoot}/osu.Desktop/bin/Release/net8.0/osu!.dll"
+ "${workspaceRoot}/osu.Desktop/bin/Release/net8.0/Ez2osu!.dll"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build osu! (Release)",
@@ -55,7 +55,7 @@
"request": "launch",
"program": "dotnet",
"args": [
- "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll",
+ "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll",
"--tournament"
],
"cwd": "${workspaceRoot}",
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..9e26dfeeb6
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 347e0f558a..e954e74761 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,90 +1,57 @@
# Contributing Guidelines
-Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience.
+感谢你对本项目的关注!再开始之前,请先阅读以下指南及代码的行为守则。在收到补丁后,我会默认你已经阅读了以下内容,并接受我会提出的审阅意见。
-## Table of contents
+## 第三方发行版说明
-1. [Reporting bugs](#reporting-bugs)
-2. [Providing general feedback](#providing-general-feedback)
-3. [Issue or discussion?](#issue-or-discussion)
-4. [Submitting pull requests](#submitting-pull-requests)
-5. [Resources](#resources)
+当前仓库基于由 [ppy](https://github.com/ppy) 的 [osu!](https://github.com/ppy/osu) 项目修改。
+- 由于本人能力有限,无法保证所有功能在每个版本均能正常使用。(我无法每次都进行完整的功能测试)
+- 可能存在与原版不同的行为。
+- 原则上可以与官方osu使用统一的数据库,不干扰配置文件。我使用client_XX.realm文件存储数据,官方使用client.realm(但建议自行备份原文件以防数据不兼容、文件损坏等)。
+- 原则上可以与官方osu使用同一账号,release版可以登录并连接官方服务器,可以正常下图、下载排行榜、保存到处成绩等;
+- 绝对不支持向官方服务器做出任何上传行为(上传谱面、成绩等)和多人游戏。
+- 可以使用第三方服务器(如有),我只能做有限支持,不保证每个版本的可用性。
+- 大部分精力会放在mania模式的功能性开发上,接受其他模式功能性补丁。
+- 我非常乐意接受任何反馈和建议,通过任何渠道(GitHub issues、邮件、QQ等),但不保证每个建议都能被采纳。
-## Reporting bugs
+如果你想提交补丁,我希望你能通过某些方式在发布PR前与我联系,这能够节省我大量的时间和精力。
+- 在你提交PR前,请先通过邮件或QQ与我联系,说明你想做的改动。
+- 我需要先确认我本地的代码是否上传到了GitHub。(否则我需要花费大量精力解决冲突问题)
-A **bug** is a situation in which there is something clearly *and objectively* wrong with the game. Examples of applicable bug reports are:
+## 代码的行为守则
-- The game crashes to desktop when I start a beatmap
-- Friends appear twice in the friend listing
-- The game slows down a lot when I play this specific map
-- A piece of text is overlapping another piece of text on the screen
+- 所有新创建的文件必须包含ppy的版权声明。新建文件时,项目应该会自动添加。
+- 遵循现有的代码风格和惯例,在提交前经过测试。
+- 新建文件尽可能放在独立文件夹中,在我提供的唯一一级分类文件夹下(如osu.Game.Rulesets.Mania.LAsEzMania),创建带有byID字样的二级文件夹
+- 尽量不要出现硬编码字符串,尽量不要使用反射,除非别无选择。
+- 除using外,尽量不要到处拉屎(比如一个地方改一行,然后到处修改)。
+- 尽量不要增加新的第三方依赖,除非功能很棒且没有更好的替代品。
+- 本地化支持:统一使用EzLocalizationManager及相关继承类进行本地化字符串管理。
-To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following:
+# English Version
-- Before opening the issue, please search for any similar existing issues using the text search bar and the issue labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been released).
-- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to include logs and screenshots as much as possible. The instructions on how to find the log files are included in the issue template.
-- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide follow-up info if we request it.
+## Third-Party Distribution Instructions
-If we cannot reproduce the issue, it is deemed low priority, or it is deemed to be specific to your setup in some way, the issue may be downgraded to a discussion. This will be done by a maintainer for you.
+The current repository is based on [ppy](https://github.com/ppy)'s [osu!](https://github.com/ppy/osu) Project modifications.
+- Due to my limited ability, I cannot guarantee that all functions will work properly in each version. (I can't do a full functional test every time)
+- There may be different behavior from the original.
+- In principle, it is possible to use a unified database with the official OSU without interfering with the configuration file. I use client_XX.realm file to store data, and the official uses client.realm (but it is recommended to back up the original file yourself in case of data incompatibility, file corruption, etc.).
+- In principle, you can use the same account as the official OSU, the release version can log in and connect to the official server, you can download the picture normally, download the leaderboard, save the results everywhere, etc.
+- Any uploads to official servers (uploading beatmaps, scores, etc.) and multiplayer games are absolutely not supported.
+- Third-party servers (if any) are available, I can only do limited support and do not guarantee the availability of each version.
+- Most of the effort will be focused on the functional development of the mania mode, accepting functional patches for other modes.
+- I am more than happy to accept any feedback and suggestions through any channel (GitHub issues, email, QQ, etc.), but there is no guarantee that every suggestion will be adopted.
-## Providing general feedback
+If you want to submit a patch, I would like you to contact me before publishing a PR in some way, which will save me a lot of time and effort.
+- Before you submit a PR, please contact me via email or QQ with the changes you want to make.
+- I need to check if my local code is uploaded to GitHub. (Otherwise I would have to spend a lot of energy resolving conflict issues)
-If you wish to:
+## Code of Conduct for Code
-- provide *subjective* feedback on the game (about how the UI looks, about how the default skin works, about game mechanics, about how the PP and scoring systems work, etc.),
-- suggest a new feature to be added to the game,
-- report a non-specific problem with the game that you think may be connected to your hardware or operating system specifically,
-
-then it is generally best to start with a **discussion** first. Discussions are a good avenue to group subjective feedback on a single topic, or gauge interest in a particular feature request.
-
-When opening a discussion, please keep in mind the following:
-
-- Use the search function to see if your idea has been proposed before, or if there is already a thread about a particular issue you wish to raise.
-- If proposing a feature, please try to explain the feature in as much detail as possible.
-- If you're reporting a non-specific problem, please provide applicable logs, screenshots, or video that illustrate the issue.
-
-If a discussion gathers enough traction, then it may be converted into an issue. This will be done by a maintainer for you.
-
-## Issue or discussion?
-
-We realise that the line between an issue and a discussion may be fuzzy, so while we ask you to use your best judgement based on the description above, please don't think about it too hard either. Feedback in a slightly wrong place is better than no feedback at all.
-
-When in doubt, it's probably best to start with a discussion first. We will escalate to issues as needed.
-
-## Submitting pull requests
-
-While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change.
-
-The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.
-
-If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library).
-
-Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
-
-- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your development environment.
-- Make sure you are familiar with [git](https://git-scm.com/) and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests).
-- Please do not make code changes via the GitHub web interface.
-- Please add tests for your changes. We expect most new features and bugfixes to have test coverage, unless the effort of adding them is prohibitive. The visual testing methodology we use is described in more detail [here](https://github.com/ppy/osu-framework/wiki/Development-and-Testing).
-- Please run tests and code style analysis (via `InspectCode.{ps1,sh}` scripts in the root of this repository) before opening the PR. This is particularly important if you're a first-time contributor, as CI will not run for your PR until we allow it to do so.
-- **Do not run the game in release configuration at any point during your testing** (the sole exception to this being benchmarks). Using release is an unnecessary and harmful practice, and can even lead to you losing your local realm database if you start making changes to the schema. The debug configuration has a completely separated full-stack environment, including a development website instance at https://dev.ppy.sh/. It is permitted to register an account on that development instance for testing purposes and not worry about multi-accounting infractions.
-
-After you're done with your changes and you wish to open the PR, please observe the following recommendations:
-
-- Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary.
-- Please pick the following target branch for your pull request:
- - `pp-dev`, if the change impacts star rating or performance points calculations for any of the rulesets,
- - `master`, otherwise.
-- Please avoid pushing untested or incomplete code.
-- Please do not force-push or rebase unless we ask you to.
-- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge.
-
-We are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience.
-
-If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue, discussion, or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can.
-
-## Resources
-
-- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on
-- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game
-- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game
-- [Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library): Contains finished and draft designs for osu!
+- All newly created files must include a copyright notice from PPY. When you create a new file, the project should be added automatically.
+- Follow existing code styles and conventions, tested before committing.
+- Place new files in a separate folder as much as possible, under the only first-level classification folder I provide (e.g. osu. Game.Rulesets.Mania.LAsEzMania), creating a secondary folder with the word byID
+- Try not to appear hardcoded strings and try not to use reflections unless there is no other choice.
+- Try not to shit everywhere except using (like changing a line in one place and then changing it everywhere).
+- Try not to add new third-party dependencies unless the functionality is great and there are no better alternatives.
+- Localization support: EzLocalizationManager and related inheritance classes are used for localized string management.
\ No newline at end of file
diff --git a/README.md b/README.md
index d87ca31f72..7315f9a40b 100644
--- a/README.md
+++ b/README.md
@@ -2,141 +2,144 @@
-# osu!
+# Ez2Lazer!
-[](https://github.com/ppy/osu/actions/workflows/ci.yml)
-[](https://github.com/ppy/osu/releases/latest)
-[](https://www.codefactor.io/repository/github/ppy/osu)
-[](https://discord.gg/ppy)
-[](https://crowdin.com/project/osu-web)
+This is always a pre-release version, maintained by me personally
-A free-to-win rhythm game. Rhythm is just a *click* away!
+## Latest release: [Windows 10+ (x64)](https://github.com/SK-la/Ez2Lazer/releases)
+- **Setup [EzResources](https://la1225-my.sharepoint.com/:f:/g/personal/la_la1225_onmicrosoft_com/EiosAbw_1C9ErYCNRD1PQvkBaYvhflOkt8G9ZKHNYuppLg?e=DWY1kn) Pack to osu datebase path.**
-This is the future – and final – iteration of the [osu!](https://osu.ppy.sh) game client which marks the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
+- A desktop platform with the [.NET 8.0 RunTime](https://dotnet.microsoft.com/download) installed.
-## Status
-
-This project is under constant development, but we do our best to keep things in a stable state. Players are encouraged to install from a release alongside their stable *osu!* client. This project will continue to evolve until we eventually reach the point where most users prefer it over the previous "osu!stable" release.
-
-A few resources are available as starting points to getting involved and understanding the project:
-
-- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
-- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
-- Track our current efforts [towards improving the game](https://github.com/orgs/ppy/projects/7/views/6).
-
-## Running osu!
-
-If you are just looking to give the game a whirl, you can grab the latest release for your platform:
-
-### Latest release:
-
-| [Windows 10+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 12+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) |
-|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------- | ------------- | ------------- |
-
-You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download).
-
-If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
-
-**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation.
-
-## Developing a custom ruleset
-
-osu! is designed to allow user-created gameplay variations, called "rulesets". Building one of these allows a developer to harness the power of the osu! beatmap library, game engine, and general UX for a new style of gameplay. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
-
-You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
-
-## Developing osu!
-
-### Prerequisites
-
-Please make sure you have the following prerequisites:
-
-- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed.
+- Develop modifications using Rider + VS Code
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed.
-### Downloading the source code
+## Build Instructions
+- Clone the repository
+```bash
-Clone the repository:
+git clone SK-la/Ez2Lazer
+git clone SK-la/osu-framework
+git clone SK-la/osu-resources
+// There is a lack of special texture resources in Resource, so it is recommended that you use the DLL in the release package to replace it after building
-```shell
-git clone https://github.com/ppy/osu
-cd osu
+build Ez2Lazer
```
-To update the source code to the latest commit, run the following command inside the `osu` directory:
+## Feature support
+(It's not always updated here)
-```shell
-git pull
-```
+### Vedio Main Background
+- Support vedio as main background (.webm)
+
+
-### Building
-#### From an IDE
+### SongSelect Ez to Filter
+- Keys Filter (One\Multi)
+- Notes by column
+- Avg\Max KPS
+
-You should load the solution via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will reduce dependencies and hide platforms that you don't care about. Valid `.slnf` files are:
-- `osu.Desktop.slnf` (most common)
-- `osu.Android.slnf`
-- `osu.iOS.slnf`
+### Freedom Speed Adjust System
-Run configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `osu! (Tests)` project/configuration. More information on this is provided [below](#contributing).
+| - | Speed | + |
+|--|-----------|--|
+| ← | Base Speed | → |
+| 0 | Scroll Speed | 401 |
-To build for mobile platforms, you will likely need to run `sudo dotnet workload restore` if you haven't done so previously. This will install Android/iOS tooling required to complete the build.
+Base Speed - Setting Speed(0~401) * MPS(Gaming ±Speed)
-#### From CLI
+
-You can also build and run *osu!* from the command-line with a single command:
-```shell
-dotnet run --project osu.Desktop
-```
-When running locally to do any kind of performance testing, make sure to add `-c Release` to the build command, as the overhead of running with the default `Debug` configuration can be large (especially when testing with local framework modifications as below).
+### New Skin System
+- Ez Pro SKin System
+ - New Ez Style SKin Sprites - 全新Ez风格皮肤素材
+ - New Dynamic real-time preview SKin Options - 全新动态实时预览皮肤选项
+ - Built-in skin.ini settings - 内置skin.ini设置
+ - New color settings, column type setting system - 全新颜色设置、列类型设置系统
-If the build fails, try to restore NuGet packages with `dotnet restore`.
+
+
-### Testing with resource/framework modifications
+- Preload skin resources when entering the game interface to reduce lag in the early stages of the game
+- Change to the Smart Subfolder drop-down list
+
-Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands:
+- Mania Playfield Support Blur and Dim Effect
-Windows:
+
-```ps
-UseLocalFramework.ps1
-UseLocalResources.ps1
-```
+- HUD Components
+-
-macOS / Linux:
-```ps
-UseLocalFramework.sh
-UseLocalResources.sh
-```
+### Pool Judgment (Empty Judgment)
-Note that these commands assume you have the relevant project(s) checked out in adjacent directories:
+- Pool判定不影响ACC、Combo,仅严格扣血,连续的Pool判将累加扣血幅度.
+- The pool hit result does not affect ACC and Combo, only strict blood deduction, and continuous pools will accumulate the blood deduction amplitude.
+> -500 < -Pool < miss < +Pool < +150
+>
+>
-```
-|- osu // this repository
-|- osu-framework
-|- osu-resources
-```
-### Code analysis
+### New Judgment Mode
-Before committing your code, please run a code formatter. This can be achieved by running `dotnet format` in the command line, or using the `Format code` command in your IDE.
+> For the time being, only the settings are implemented, and the actual parameters will be matched in the future
+>
+> 暂时仅实现设置,未来匹配实际参数
-We have adopted some cross-platform, compiler integrated analyzers. They can provide warnings when you are editing, building inside IDE or from command line, as-if they are provided by the compiler itself.
+
-JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it from PowerShell with `.\InspectCode.ps1`. Alternatively, you can install ReSharper or use Rider to get inline support in your IDE of choice.
+- Ez2AC: LN-NoRelease (Press and hold LN-tail to perfect)
+> { 18.0, 32.0, 64.0, 80.0, 100.0, 120.0 }
-## Contributing
+- O2Jam: None-Press is miss
+> coolRange = 7500.0 / bpm;
+> goodRange = 22500.0 / bpm;
+> badRange = 31250.0 / bpm;
-When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in the most effective way possible.
+- IIDX (instant): LN-NoRelease
+> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 }
+
+- Malody (instant): LN-NoRelease
+> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 }
+
+Audio System
+
+- 增加采样打断重放(防止全key音谱多音轨重叠变成噪音)
+- Added sampling interruption playback (to prevent overlapping multiple tracks of the full key note spectrum from becoming noise)
+- 选歌界面增加预览keysound和故事板背景音乐
+- Added preview keysound and storyboard background music to the song selection interface
+
+### Static Score
+- Space Graph
+
+
+
+- Column One by One
+
+
+
+### Other
+- Scale Only Mode
+
+
+
+## Mod
+
+
+
+
+## Special Thanks
+- [osu!](https://github.com/ppy/osu): The original game and framework. The code is very strong and elegant.
+- [YuLiangSSS](https://osu.ppy.sh/users/15889644): Many fun mods contributed.
-If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web).
-We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so.
## Licence
diff --git a/SkinScriptingImplementation/CHANGES.md b/SkinScriptingImplementation/CHANGES.md
new file mode 100644
index 0000000000..d9769142f0
--- /dev/null
+++ b/SkinScriptingImplementation/CHANGES.md
@@ -0,0 +1,96 @@
+# 需要修改的文件列表
+
+以下是实现皮肤脚本系统所需修改的所有文件列表,包括修改内容的简要说明。
+
+## 修改的现有文件
+
+### 1. `osu.Game/Skinning/Skin.cs`
+
+**主要修改:**
+- 添加了 `Scripts` 属性,用于存储加载的脚本
+- 添加了 `LoadComplete()` 和 `LoadScripts()` 方法,用于加载皮肤脚本
+- 添加了 `GetScriptFiles()` 和 `GetStream()` 虚拟方法,供子类实现
+- 修改了 `Dispose()` 方法,确保脚本资源被正确释放
+
+### 2. `osu.Game/Skinning/LegacySkin.cs`
+
+**主要修改:**
+- 重写了 `GetScriptFiles()` 方法,实现从皮肤文件中查找 .lua 脚本文件
+
+### 3. `osu.Game/Skinning/SkinManager.cs`
+
+**主要修改:**
+- 添加了 `scriptManager` 字段,用于存储脚本管理器实例
+- 在构造函数中初始化脚本管理器并注册到 RealmAccess
+- 确保皮肤切换时脚本也得到更新
+
+### 4. `osu.Game/Skinning/SkinnableDrawable.cs`
+
+**主要修改:**
+- 添加了 `[Resolved]` 依赖注入 `SkinScriptManager`
+- 添加了 `LoadComplete()` 方法,在组件加载完成后通知脚本
+- 添加了 `NotifyScriptsOfComponentLoad()` 方法,用于通知脚本管理器组件已加载
+
+## 新增的文件
+
+### 1. `osu.Game/Skinning/Scripting/ISkinScriptHost.cs`
+
+提供脚本与游戏交互的主机接口,定义了脚本可以访问的资源和功能。
+
+### 2. `osu.Game/Skinning/Scripting/SkinScript.cs`
+
+表示单个Lua脚本,包含加载、执行脚本及处理各种事件的逻辑。
+
+### 3. `osu.Game/Skinning/Scripting/SkinScriptInterface.cs`
+
+为Lua脚本提供API,封装对游戏系统的调用,确保安全且受控的访问。
+
+### 4. `osu.Game/Skinning/Scripting/SkinScriptManager.cs`
+
+管理所有活跃的皮肤脚本,协调脚本加载和事件分发。
+
+### 5. `osu.Game/Skinning/Scripting/SkinScriptingConfig.cs`
+
+管理脚本配置设置,包括启用/禁用脚本和权限列表。
+
+### 6. `osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs`
+
+提供脚本设置用户界面,允许用户启用/禁用脚本并导入新脚本。
+
+### 7. `osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs`
+
+在游戏启动时注册脚本设置到设置界面。
+
+### 8. `osu.Game/Overlays/Dialog/FileImportFaultDialog.cs`
+
+用于显示文件导入错误的对话框。
+
+### 9. `osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs`
+
+为Mania模式提供特定的脚本扩展,允许脚本访问和修改Mania特有的元素。
+
+## 示例文件
+
+### 1. `ExampleSkinScript.lua`
+
+通用皮肤脚本示例,演示基本功能和API用法。
+
+### 2. `ExampleManiaSkinScript.lua`
+
+Mania模式特定皮肤脚本示例,演示如何使用Mania特有的API。
+
+## 需要安装的依赖
+
+- MoonSharp.Interpreter (2.0.0) - Lua脚本引擎
+
+## 实施步骤
+
+1. 安装必要的NuGet包
+2. 添加新文件到项目中
+3. 按照列表修改现有文件
+4. 编译并测试基本功能
+5. 测试示例脚本
+
+## 对现有代码的影响
+
+修改尽量保持最小化,主要通过添加方法和属性来扩展现有类,而不是修改核心逻辑。所有脚本执行都在try-catch块中,确保脚本错误不会影响游戏稳定性。
diff --git a/SkinScriptingImplementation/Dependencies/nuget-packages.txt b/SkinScriptingImplementation/Dependencies/nuget-packages.txt
new file mode 100644
index 0000000000..b962708303
--- /dev/null
+++ b/SkinScriptingImplementation/Dependencies/nuget-packages.txt
@@ -0,0 +1 @@
+MoonSharp.Interpreter 2.0.0
diff --git a/SkinScriptingImplementation/ExampleManiaSkinScript.lua b/SkinScriptingImplementation/ExampleManiaSkinScript.lua
new file mode 100644
index 0000000000..c0adafdd20
--- /dev/null
+++ b/SkinScriptingImplementation/ExampleManiaSkinScript.lua
@@ -0,0 +1,160 @@
+-- Mania-specific skin script example
+-- This script shows how to customize Mania mode skin components
+
+-- Script description and metadata
+SCRIPT_DESCRIPTION = "Mania模式特定的皮肤脚本示例,展示如何自定义下落式键盘模式的外观和行为"
+SCRIPT_VERSION = "1.0"
+SCRIPT_AUTHOR = "osu!team"
+
+-- Cache for column information
+local columnData = {}
+
+-- Called when the script is first loaded
+function onLoad()
+ osu.Log("Mania skin script loaded!", "info")
+ osu.SubscribeToEvent("ManiaColumnHit")
+ osu.SubscribeToEvent("ManiaHoldActivated")
+
+ -- Initialize column data if we're in mania mode
+ if osu.GetRulesetName() == "mania" then
+ local columnCount = mania.GetColumnCount()
+ osu.Log("Mania mode detected with " .. columnCount .. " columns", "info")
+
+ -- Store information about each column
+ for i = 0, columnCount - 1 do
+ columnData[i] = {
+ binding = mania.GetColumnBinding(i),
+ width = mania.GetColumnWidth(i),
+ lastHitTime = 0,
+ isHolding = false
+ }
+ osu.Log("Column " .. i .. " has binding " .. columnData[i].binding, "debug")
+ }
+ end
+end
+
+-- Called when a component is loaded
+function onComponentLoaded(component)
+ if component.Type and component.Type.Name == "ManiaStageComponent" then
+ osu.Log("Mania stage component loaded", "info")
+
+ -- Here you could modify the appearance of the mania stage
+ -- For example, change colors, sizes, etc.
+ elseif component.Type and component.Type.Name == "ManiaNote" then
+ osu.Log("Mania note component loaded", "debug")
+
+ -- You could customize individual notes here
+ -- For example, change the color based on the column
+ local note = component
+ if note.Column ~= nil then
+ local columnIndex = mania.GetNoteColumn(note)
+
+ -- Example: Apply different styling to different columns
+ if columnIndex % 2 == 0 then
+ -- Even columns get one style
+ note.Colour = {R = 0.9, G = 0.4, B = 0.4, A = 1.0}
+ else
+ -- Odd columns get another style
+ note.Colour = {R = 0.4, G = 0.4, B = 0.9, A = 1.0}
+ end
+ end
+ end
+end
+
+-- Called when a game event occurs
+function onGameEvent(eventName, data)
+ if eventName == "ManiaColumnHit" then
+ local columnIndex = data.ColumnIndex
+
+ if columnData[columnIndex] then
+ columnData[columnIndex].lastHitTime = osu.GetCurrentTime()
+
+ -- Example: Create a visual effect when a column is hit
+ -- This would require a custom component to be defined elsewhere
+ osu.Log("Hit on column " .. columnIndex, "debug")
+ end
+ elseif eventName == "ManiaHoldActivated" then
+ local columnIndex = data.ColumnIndex
+
+ if columnData[columnIndex] then
+ columnData[columnIndex].isHolding = true
+
+ -- Example: Apply a continuous effect while holding
+ osu.Log("Hold started on column " .. columnIndex, "debug")
+ end
+ elseif eventName == "ManiaHoldReleased" then
+ local columnIndex = data.ColumnIndex
+
+ if columnData[columnIndex] then
+ columnData[columnIndex].isHolding = false
+
+ -- Example: End continuous effects when holding stops
+ osu.Log("Hold released on column " .. columnIndex, "debug")
+ end
+ end
+end
+
+-- Called when a judgement result is received
+function onJudgement(result)
+ if result.HitObject and result.HitObject.Column ~= nil then
+ local columnIndex = result.HitObject.Column
+
+ -- Example: Play different sounds based on column and hit result
+ if result.Type == "Perfect" then
+ osu.Log("Perfect hit on column " .. columnIndex, "info")
+
+ -- Example: Custom sound per column
+ if columnIndex % 2 == 0 then
+ osu.PlaySample("normal-hitnormal")
+ else
+ osu.PlaySample("normal-hitwhistle")
+ end
+ end
+ end
+end
+
+-- Called when an input event occurs
+function onInputEvent(event)
+ -- Example: Map keyboard events to column effects
+ if event.Key then
+ -- Check if the key corresponds to a column binding
+ for i = 0, #columnData do
+ if columnData[i] and columnData[i].binding == tostring(event.Key) then
+ osu.Log("Input detected for column " .. i, "debug")
+
+ -- Here you could create custom input visualizations
+ -- This is especially useful for key overlay effects
+ end
+ end
+ end
+end
+
+-- Called every frame for continuous effects
+function update()
+ local currentTime = osu.GetCurrentTime()
+
+ -- Example: Create pulsing effects on recently hit columns
+ for i = 0, #columnData do
+ if columnData[i] then
+ local timeSinceHit = currentTime - columnData[i].lastHitTime
+
+ if timeSinceHit < 500 then -- 500ms of effect
+ -- Calculate a decay effect (1.0 -> 0.0 over 500ms)
+ local intensity = 1.0 - (timeSinceHit / 500)
+
+ -- Here you would apply the effect to column visualizations
+ -- Example: column.Glow = intensity
+ end
+
+ -- Apply continuous effects to held columns
+ if columnData[i].isHolding then
+ -- Example: Create pulsing or glowing effects while holding
+ -- local pulseAmount = math.sin(currentTime / 100) * 0.2 + 0.8
+ -- column.HoldEffectIntensity = pulseAmount
+ end
+ end
+ end
+end
+
+-- Return true to indicate the script loaded successfully
+return true
diff --git a/SkinScriptingImplementation/ExampleSkinScript.lua b/SkinScriptingImplementation/ExampleSkinScript.lua
new file mode 100644
index 0000000000..856d7fea46
--- /dev/null
+++ b/SkinScriptingImplementation/ExampleSkinScript.lua
@@ -0,0 +1,66 @@
+-- Example Skin Script for osu!
+-- This script shows how to customize skin components with Lua scripting
+
+-- Script description and metadata
+SCRIPT_DESCRIPTION = "基础皮肤脚本示例,展示如何自定义皮肤组件外观和行为"
+SCRIPT_VERSION = "1.0"
+SCRIPT_AUTHOR = "osu!team"
+
+-- Called when the script is first loaded
+-- This is where you can set up any initial state or subscribe to events
+function onLoad()
+ osu.Log("Skin script loaded!", "info")
+ osu.SubscribeToEvent("HitEvent")
+ osu.SubscribeToEvent("InputEvent")
+end
+
+-- Called when a skinnable component is loaded
+-- You can modify components or react to their creation
+function onComponentLoaded(component)
+ osu.Log("Component loaded: " .. tostring(component), "debug")
+
+ -- Example: Make combo counter text larger if it's a DefaultComboCounter
+ if component.Type and component.Type.Name == "DefaultComboCounter" then
+ if component.CountDisplay then
+ component.CountDisplay.Scale = {X = 1.5, Y = 1.5}
+ osu.Log("Modified combo counter size", "info")
+ end
+ end
+end
+
+-- Called when a game event occurs
+-- Events include things like hit events, misses, combo breaks, etc.
+function onGameEvent(eventName, data)
+ if eventName == "HitEvent" then
+ osu.Log("Hit event received!", "debug")
+ -- You can trigger sound effects or visual effects here
+ if data.Result and data.Result.Type == "Great" then
+ osu.PlaySample("applause")
+ end
+ end
+end
+
+-- Called when a judgement result is received
+-- This includes hit results, misses, etc.
+function onJudgement(result)
+ -- Example: Play a custom sound on perfect hits
+ if result.Type == "Perfect" then
+ osu.Log("Perfect hit!", "info")
+ end
+end
+
+-- Called when an input event occurs
+-- This includes key presses, mouse clicks, etc.
+function onInputEvent(event)
+ osu.Log("Input event: " .. tostring(event), "debug")
+end
+
+-- Called every frame
+-- Use this for continuous animations or effects
+function update()
+ -- Example: Create pulsing effects or continuous animations
+ -- Note: Be careful with performance in this function
+end
+
+-- Return true to indicate the script loaded successfully
+return true
diff --git a/SkinScriptingImplementation/README.md b/SkinScriptingImplementation/README.md
new file mode 100644
index 0000000000..e93b554240
--- /dev/null
+++ b/SkinScriptingImplementation/README.md
@@ -0,0 +1,169 @@
+# 皮肤脚本系统 (Skin Scripting System)
+
+这个实现添加了对外部Lua脚本的支持,允许皮肤制作者通过脚本定制皮肤的行为和外观。
+
+## 实现概述
+
+这个系统使用MoonSharp作为Lua脚本引擎,并通过以下关键组件实现:
+
+1. **脚本接口** - 为皮肤脚本提供与游戏交互的API
+2. **脚本管理器** - 负责加载、执行和管理皮肤脚本
+3. **对现有代码的修改** - 在关键点调用脚本回调函数
+
+## 安装和使用
+
+### 安装
+
+1. 安装MoonSharp NuGet包:
+ ```
+ dotnet add package MoonSharp.Interpreter --version 2.0.0
+ ```
+
+2. 将`SkinScriptingImplementation`文件夹中的所有文件复制到对应的项目文件夹中,保持相同的目录结构。
+
+### 创建皮肤脚本
+
+1. 创建一个`.lua`扩展名的文件
+2. 将该文件放入你的皮肤文件夹中
+3. 当皮肤加载时,脚本会自动被加载和执行
+
+### 管理脚本
+
+皮肤脚本系统提供了用户界面来管理皮肤脚本:
+
+1. 转到`设置 -> 皮肤 -> 皮肤脚本`部分
+2. 使用`启用皮肤脚本`选项来全局启用或禁用脚本功能
+3. 使用`从文件导入脚本`按钮将新脚本添加到当前皮肤
+4. 在`可用脚本`列表中,可以单独启用或禁用每个脚本
+
+### 脚本元数据
+
+脚本可以包含以下元数据变量:
+
+```lua
+-- 脚本描述信息,将显示在设置中
+SCRIPT_DESCRIPTION = "这个脚本的功能描述"
+SCRIPT_VERSION = "1.0"
+SCRIPT_AUTHOR = "作者名称"
+```
+
+## Lua脚本API
+
+脚本可以实现以下回调函数:
+
+```lua
+-- 脚本加载时调用
+function onLoad()
+ -- 初始化工作,订阅事件等
+end
+
+-- 当皮肤组件被加载时调用
+function onComponentLoaded(component)
+ -- 你可以修改组件或对其创建做出反应
+end
+
+-- 当游戏事件发生时调用
+function onGameEvent(eventName, data)
+ -- 处理游戏事件
+end
+
+-- 当判定结果产生时调用
+function onJudgement(result)
+ -- 根据判定结果创建效果
+end
+
+-- 当输入事件发生时调用
+function onInputEvent(event)
+ -- 对输入事件做出反应
+end
+
+-- 每帧调用,用于连续动画或效果
+function update()
+ -- 创建连续动画或效果
+end
+```
+
+### 全局API
+
+所有脚本都可以访问通过`osu`对象的以下功能:
+
+```lua
+-- 获取当前谱面标题
+osu.GetBeatmapTitle()
+
+-- 获取当前谱面艺术家
+osu.GetBeatmapArtist()
+
+-- 获取当前规则集名称
+osu.GetRulesetName()
+
+-- 创建新组件
+osu.CreateComponent(componentType)
+
+-- 获取纹理
+osu.GetTexture(name)
+
+-- 获取音频样本
+osu.GetSample(name)
+
+-- 播放音频样本
+osu.PlaySample(name)
+
+-- 订阅游戏事件
+osu.SubscribeToEvent(eventName)
+
+-- 记录日志
+osu.Log(message, level) -- level可以是"debug", "info", "warning", "error"
+```
+
+### Mania模式特定API
+
+在Mania模式下,脚本还可以通过`mania`对象访问以下功能:
+
+```lua
+-- 获取列数
+mania.GetColumnCount()
+
+-- 获取音符所在的列
+mania.GetNoteColumn(note)
+
+-- 获取列绑定
+mania.GetColumnBinding(column)
+
+-- 获取列宽度
+mania.GetColumnWidth(column)
+```
+
+## 示例脚本
+
+请参考提供的示例脚本:
+- [ExampleSkinScript.lua](ExampleSkinScript.lua) - 通用皮肤脚本示例
+- [ExampleManiaSkinScript.lua](ExampleManiaSkinScript.lua) - Mania模式特定皮肤脚本示例
+
+## 修改说明
+
+以下文件已被修改以支持皮肤脚本系统:
+
+1. `osu.Game/Skinning/Skin.cs` - 添加了脚本加载和管理功能
+2. `osu.Game/Skinning/LegacySkin.cs` - 实现了脚本文件查找
+3. `osu.Game/Skinning/SkinManager.cs` - 初始化脚本管理器
+4. `osu.Game/Skinning/SkinnableDrawable.cs` - 添加了组件加载通知
+
+新增的文件:
+
+1. `osu.Game/Skinning/Scripting/*.cs` - 脚本系统核心类
+2. `osu.Game/Rulesets/Mania/Skinning/Scripting/*.cs` - Mania模式特定脚本扩展
+
+## 限制和注意事项
+
+1. 脚本在沙箱环境中运行,访问权限有限
+2. 过于复杂的脚本可能会影响性能
+3. 脚本API可能会随着游戏更新而变化
+
+## 故障排除
+
+如果脚本无法正常工作:
+
+1. 检查游戏日志中的脚本错误信息
+2. 确保脚本文件格式正确(UTF-8编码,无BOM)
+3. 确保脚本没有语法错误
diff --git a/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs b/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs
new file mode 100644
index 0000000000..dd4e91753a
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs
@@ -0,0 +1,17 @@
+using osu.Framework.Allocation;
+using osu.Game.Skinning.Scripting;
+
+namespace osu.Game
+{
+ public partial class OsuGame
+ {
+ private SkinScriptingOverlayRegistration scriptingRegistration;
+
+ [BackgroundDependencyLoader]
+ private void loadSkinScripting()
+ {
+ // 添加皮肤脚本设置注册组件
+ Add(scriptingRegistration = new SkinScriptingOverlayRegistration());
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs b/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs
new file mode 100644
index 0000000000..58cb5c61b5
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs
@@ -0,0 +1,31 @@
+using System;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Overlays.Dialog;
+
+namespace osu.Game.Overlays.Dialog
+{
+ ///
+ /// 文件导入失败时显示的对话框。
+ ///
+ public partial class FileImportFaultDialog : PopupDialog
+ {
+ ///
+ /// 初始化 类的新实例。
+ ///
+ /// 错误信息。
+ public FileImportFaultDialog(string errorMessage)
+ {
+ Icon = FontAwesome.Regular.TimesCircle;
+ HeaderText = "导入失败";
+ BodyText = errorMessage;
+
+ Buttons = new PopupDialogButton[]
+ {
+ new PopupDialogOkButton
+ {
+ Text = "确定",
+ }
+ };
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs
new file mode 100644
index 0000000000..775cce0b38
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs
@@ -0,0 +1,316 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable disable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Localisation;
+using osu.Framework.Logging;
+using osu.Game.Database;
+using osu.Game.Graphics;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Localisation;
+using osu.Game.Overlays.SkinEditor;
+using osu.Game.Screens.Select;
+using osu.Game.Skinning;
+using osuTK;
+using Realms;
+using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
+using osu.Game.Skinning.Scripting;
+using osu.Game.Skinning.Scripting.Overlays;
+
+namespace osu.Game.Overlays.Settings.Sections
+{
+ public partial class SkinSection : SettingsSection
+ {
+ private SkinSettingsDropdown skinDropdown;
+ private SkinScriptingSettingsSection scriptingSection;
+
+ public override LocalisableString Header => SkinSettingsStrings.SkinSectionHeader;
+
+ public override Drawable CreateIcon() => new SpriteIcon
+ {
+ Icon = OsuIcon.SkinB
+ };
+
+ private static readonly Live random_skin_info = new SkinInfo
+ {
+ ID = SkinInfo.RANDOM_SKIN,
+ Name = "",
+ }.ToLiveUnmanaged();
+
+ private readonly List> dropdownItems = new List>();
+
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ [Resolved]
+ private RealmAccess realm { get; set; }
+
+ private IDisposable realmSubscription; [BackgroundDependencyLoader(permitNulls: true)]
+ private void load([CanBeNull] SkinEditorOverlay skinEditor)
+ {
+ Children = new Drawable[]
+ {
+ skinDropdown = new SkinSettingsDropdown
+ {
+ AlwaysShowSearchBar = true,
+ AllowNonContiguousMatching = true,
+ LabelText = SkinSettingsStrings.CurrentSkin,
+ Current = skins.CurrentSkinInfo,
+ Keywords = new[] { @"skins" },
+ },
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Horizontal,
+ Spacing = new Vector2(5, 0),
+ Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS },
+ Children = new Drawable[]
+ {
+ // This is all super-temporary until we move skin settings to their own panel / overlay.
+ new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 },
+ new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 },
+ new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 },
+ }
+ },
+ new SettingsButton
+ {
+ Text = SkinSettingsStrings.SkinLayoutEditor,
+ Action = () => skinEditor?.ToggleVisibility(),
+ },
+ scriptingSection = new SkinScriptingSettingsSection(),
+ };
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ realmSubscription = realm.RegisterForNotifications(_ => realm.Realm.All()
+ .Where(s => !s.DeletePending)
+ .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged);
+
+ skinDropdown.Current.BindValueChanged(skin =>
+ {
+ if (skin.NewValue == random_skin_info)
+ {
+ // before selecting random, set the skin back to the previous selection.
+ // this is done because at this point it will be random_skin_info, and would
+ // cause SelectRandomSkin to be unable to skip the previous selection.
+ skins.CurrentSkinInfo.Value = skin.OldValue;
+ skins.SelectRandomSkin();
+ }
+ });
+ }
+
+ private void skinsChanged(IRealmCollection sender, ChangeSet changes)
+ {
+ // This can only mean that realm is recycling, else we would see the protected skins.
+ // Because we are using `Live<>` in this class, we don't need to worry about this scenario too much.
+ if (!sender.Any())
+ return;
+
+ // For simplicity repopulate the full list.
+ // In the future we should change this to properly handle ChangeSet events.
+ dropdownItems.Clear();
+
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.EZ2_SKIN).ToLive(realm));
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.SBI_SKIN).ToLive(realm));
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm));
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm));
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm));
+ dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm));
+
+ dropdownItems.Add(random_skin_info);
+
+ foreach (var skin in sender.Where(s => !s.Protected))
+ dropdownItems.Add(skin.ToLive(realm));
+
+ Schedule(() => skinDropdown.Items = dropdownItems);
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ realmSubscription?.Dispose();
+ }
+
+ private partial class SkinSettingsDropdown : SettingsDropdown>
+ {
+ protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl();
+
+ private partial class SkinDropdownControl : DropdownControl
+ {
+ protected override LocalisableString GenerateItemText(Live item) => item.ToString();
+ }
+ }
+
+ public partial class RenameSkinButton : SettingsButton, IHasPopover
+ {
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ private Bindable currentSkin;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Text = CommonStrings.Rename;
+ Action = this.ShowPopover;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ currentSkin = skins.CurrentSkin.GetBoundCopy();
+ currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
+ }
+
+ public Popover GetPopover()
+ {
+ return new RenameSkinPopover();
+ }
+ }
+
+ public partial class ExportSkinButton : SettingsButton
+ {
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ private Bindable currentSkin;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Text = CommonStrings.Export;
+ Action = export;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ currentSkin = skins.CurrentSkin.GetBoundCopy();
+ currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
+ }
+
+ private void export()
+ {
+ try
+ {
+ skins.ExportCurrentSkin();
+ }
+ catch (Exception e)
+ {
+ Logger.Log($"Could not export current skin: {e.Message}", level: LogLevel.Error);
+ }
+ }
+ }
+
+ public partial class DeleteSkinButton : DangerousSettingsButton
+ {
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private IDialogOverlay dialogOverlay { get; set; }
+
+ private Bindable currentSkin;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Text = WebCommonStrings.ButtonsDelete;
+ Action = delete;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ currentSkin = skins.CurrentSkin.GetBoundCopy();
+ currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
+ }
+
+ private void delete()
+ {
+ dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value));
+ }
+ }
+
+ public partial class RenameSkinPopover : OsuPopover
+ {
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ private readonly FocusedTextBox textBox;
+
+ public RenameSkinPopover()
+ {
+ AutoSizeAxes = Axes.Both;
+ Origin = Anchor.TopCentre;
+
+ RoundedButton renameButton;
+
+ Child = new FillFlowContainer
+ {
+ Direction = FillDirection.Vertical,
+ AutoSizeAxes = Axes.Y,
+ Width = 250,
+ Spacing = new Vector2(10f),
+ Children = new Drawable[]
+ {
+ textBox = new FocusedTextBox
+ {
+ PlaceholderText = @"Skin name",
+ FontSize = OsuFont.DEFAULT_FONT_SIZE,
+ RelativeSizeAxes = Axes.X,
+ SelectAllOnFocus = true,
+ },
+ renameButton = new RoundedButton
+ {
+ Height = 40,
+ RelativeSizeAxes = Axes.X,
+ MatchingFilter = true,
+ Text = "Save",
+ }
+ }
+ };
+
+ renameButton.Action += rename;
+ textBox.OnCommit += (_, _) => rename();
+ }
+
+ protected override void PopIn()
+ {
+ textBox.Text = skins.CurrentSkinInfo.Value.Value.Name;
+ textBox.TakeFocus();
+
+ base.PopIn();
+ }
+
+ private void rename() => skins.CurrentSkinInfo.Value.PerformWrite(skin =>
+ {
+ skin.Name = textBox.Text;
+ PopOut();
+ });
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs b/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs
new file mode 100644
index 0000000000..a6a5ac7609
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs
@@ -0,0 +1,81 @@
+using MoonSharp.Interpreter;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.UI;
+
+namespace osu.Game.Rulesets.Mania.Skinning.Scripting
+{
+ ///
+ /// Provides mania-specific extensions for skin scripts.
+ ///
+ [MoonSharpUserData]
+ public class ManiaSkinScriptExtensions
+ {
+ private readonly ManiaAction[] columnBindings;
+ private readonly StageDefinition stageDefinition;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stage this extension is for.
+ public ManiaSkinScriptExtensions(Stage stage)
+ {
+ stageDefinition = stage.Definition;
+
+ // Store column bindings
+ columnBindings = new ManiaAction[stageDefinition.Columns];
+ for (int i = 0; i < stageDefinition.Columns; i++)
+ {
+ columnBindings[i] = stageDefinition.GetActionForColumn(i);
+ }
+ }
+
+ ///
+ /// Gets the number of columns in the stage.
+ ///
+ /// The number of columns.
+ [MoonSharpVisible(true)]
+ public int GetColumnCount()
+ {
+ return stageDefinition.Columns;
+ }
+
+ ///
+ /// Gets the column index for a specific note.
+ ///
+ /// The note.
+ /// The column index.
+ [MoonSharpVisible(true)]
+ public int GetNoteColumn(Note note)
+ {
+ return note.Column;
+ }
+
+ ///
+ /// Gets the binding (action) for a specific column.
+ ///
+ /// The column index.
+ /// The binding action as a string.
+ [MoonSharpVisible(true)]
+ public string GetColumnBinding(int column)
+ {
+ if (column < 0 || column >= columnBindings.Length)
+ return "Invalid";
+
+ return columnBindings[column].ToString();
+ }
+
+ ///
+ /// Gets the width of a specific column.
+ ///
+ /// The column index.
+ /// The column width.
+ [MoonSharpVisible(true)]
+ public float GetColumnWidth(int column)
+ {
+ if (column < 0 || column >= stageDefinition.Columns)
+ return 0;
+
+ return stageDefinition.ColumnWidths[column];
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs b/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs
new file mode 100644
index 0000000000..18f1606e82
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs
@@ -0,0 +1,369 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using JetBrains.Annotations;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Game.Audio;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.Extensions;
+using osu.Game.IO;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Screens.Play.HUD;
+using osu.Game.Screens.Play.HUD.HitErrorMeters;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Skinning
+{
+ public class LegacySkin : Skin
+ {
+ protected virtual bool AllowManiaConfigLookups => true;
+
+ ///
+ /// Whether this skin can use samples with a custom bank (custom sample set in stable terminology).
+ /// Added in order to match sample lookup logic from stable (in stable, only the beatmap skin could use samples with a custom sample bank).
+ ///
+ protected virtual bool UseCustomSampleBanks => false;
+
+ private readonly Dictionary maniaConfigurations = new Dictionary();
+
+ [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
+ public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
+ : this(skin, resources, null)
+ {
+ }
+
+ ///
+ /// Construct a new legacy skin instance.
+ ///
+ /// The model for this skin.
+ /// Access to raw game resources.
+ /// An optional fallback store which will be used for file lookups that are not serviced by realm user storage.
+ /// The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file.
+ protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore, string configurationFilename = @"skin.ini")
+ : base(skin, resources, fallbackStore, configurationFilename)
+ {
+ }
+
+ protected override IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage)
+ => new LegacyTextureLoaderStore(base.CreateTextureLoaderStore(resources, storage));
+
+ protected override void ParseConfigurationStream(Stream stream)
+ {
+ base.ParseConfigurationStream(stream);
+
+ stream.Seek(0, SeekOrigin.Begin);
+
+ using (LineBufferedReader reader = new LineBufferedReader(stream))
+ {
+ var maniaList = new LegacyManiaSkinDecoder().Decode(reader);
+
+ foreach (var config in maniaList)
+ maniaConfigurations[config.Keys] = config;
+ }
+ }
+
+ ///
+ /// Gets a list of script files in the skin.
+ ///
+ /// A list of script file names.
+ protected override IEnumerable GetScriptFiles()
+ {
+ // Look for .lua script files in the skin
+ return SkinInfo.Files.Where(f => f.Filename.EndsWith(".lua", StringComparison.OrdinalIgnoreCase))
+ .Select(f => f.Filename);
+ }
+
+ [SuppressMessage("ReSharper", "RedundantAssignment")] // for `wasHit` assignments used in `finally` debug logic
+ public override IBindable? GetConfig(TLookup lookup)
+ {
+ bool wasHit = true;
+
+ try
+ {
+ switch (lookup)
+ {
+ case GlobalSkinColours colour:
+ switch (colour)
+ {
+ case GlobalSkinColours.ComboColours:
+ var comboColours = Configuration.ComboColours;
+ if (comboColours != null)
+ return SkinUtils.As(new Bindable>(comboColours));
+
+ break;
+
+ default:
+ return SkinUtils.As(getCustomColour(Configuration, colour.ToString()));
+ }
+
+ break;
+
+ case SkinConfiguration.LegacySetting setting:
+ switch (setting)
+ {
+ case SkinConfiguration.LegacySetting.Version:
+ return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION));
+ }
+
+ break;
+
+ // handled by ruleset-specific skin classes.
+ case LegacyManiaSkinConfigurationLookup maniaLookup:
+ wasHit = false;
+ break;
+
+ case SkinCustomColourLookup customColour:
+ return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString()));
+
+ case LegacySkinHitCircleLookup legacyHitCircleLookup:
+ switch (legacyHitCircleLookup.Detail)
+ {
+ case LegacySkinHitCircleLookup.DetailType.HitCircleNormalPathTint:
+ return SkinUtils.As(new Bindable(Configuration.HitCircleNormalPathTint ?? Color4.White));
+
+ case LegacySkinHitCircleLookup.DetailType.HitCircleHoverPathTint:
+ return SkinUtils.As(new Bindable(Configuration.HitCircleHoverPathTint ?? Color4.White));
+
+ case LegacySkinHitCircleLookup.DetailType.Count:
+ wasHit = false;
+ break;
+ }
+
+ break;
+
+ case LegacySkinNoteSheetLookup legacyNoteSheetLookup:
+ return SkinUtils.As(new Bindable(Configuration.NoteBodyWidth ?? 128));
+
+ case SkinConfigurationLookup skinLookup:
+ return handleLegacySkinLookup(skinLookup);
+ }
+
+ wasHit = false;
+ return null;
+ }
+ finally
+ {
+ LogLookupDebug(this, lookup, wasHit ? LookupDebugType.Hit : LookupDebugType.Miss);
+ }
+ }
+
+ public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
+ {
+ if (base.GetDrawableComponent(lookup) is Drawable d)
+ return d;
+
+ switch (lookup)
+ {
+ case SkinnableSprite.SpriteComponentLookup sprite:
+ return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize);
+
+ case SkinComponentsContainerLookup _:
+ return null;
+
+ case GameplaySkinComponentLookup resultComponent:
+ return getResult(resultComponent.Component);
+
+ case GameplaySkinComponentLookup bhe:
+ if (Configuration.LegacyVersion < 2.2m)
+ return null;
+
+ break;
+
+ case JudgementLineStyleLookup judgementLine:
+ return findProvider(nameof(JudgementLineStyleLookup.Type), judgementLine.Type);
+
+ default:
+ return findProvider(nameof(ISkinComponentLookup.Lookup), lookup.Lookup);
+ }
+
+ return null;
+ }
+
+ private Drawable? findProvider(string lookupName, object lookupValue)
+ {
+ var providedType = GetType().Assembly.GetTypes()
+ .Where(t => !t.IsInterface && !t.IsAbstract)
+ .FirstOrDefault(t =>
+ {
+ var interfaces = t.GetInterfaces();
+
+ return interfaces.Any(i => i.IsGenericType &&
+ i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) &&
+ i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType());
+ });
+
+ if (providedType == null)
+ return null;
+
+ var constructor = providedType.GetConstructor(new[] { typeof(LegacySkinConfiguration), typeof(ISkin) });
+
+ if (constructor == null)
+ return null;
+
+ var instance = constructor.Invoke(new object[] { Configuration, this });
+
+ var interfaceType = instance.GetType().GetInterfaces()
+ .First(i => i.IsGenericType &&
+ i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) &&
+ i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType());
+
+ var providerType = interfaceType.GetGenericTypeDefinition().MakeGenericType(interfaceType.GenericTypeArguments[0], lookupValue.GetType());
+
+ var methodInfo = providerType.GetMethod(nameof(ILegacySkinComponentProvider.GetDrawableComponent),
+ new[] { lookupValue.GetType() });
+
+ var component = methodInfo?.Invoke(instance, new[] { lookupValue }) as Drawable;
+
+ return component;
+ }
+
+ private IBindable? handleLegacySkinLookup(SkinConfigurationLookup lookup)
+ {
+ switch (lookup.Lookup)
+ {
+ case SkinConfiguration.SliderStyle:
+ {
+ var style = Configuration.SliderStyle ?? (Configuration.Version < 2.0m ? SliderStyle.Segmented : SliderStyle.Gradient);
+ return SkinUtils.As(new Bindable(style));
+ }
+
+ case SkinConfiguration.ScoringVisible:
+ return SkinUtils.As(new Bindable(Configuration.ScoringVisible ?? true));
+
+ case SkinConfiguration.ComboPerformed:
+ return SkinUtils.As(new Bindable(Configuration.ComboPerformed ?? true));
+
+ case SkinConfiguration.ComboTaskbarPopover:
+ return SkinUtils.As(new Bindable(Configuration.ComboTaskbarPopover ?? true));
+
+ case SkinConfiguration.HitErrorStyle:
+ return SkinUtils.As(new Bindable(Configuration.HitErrorStyle ?? HitErrorStyle.Bottom));
+
+ case SkinConfiguration.MainHUDLayoutMode:
+ return SkinUtils.As(new Bindable(Configuration.MainHUDLayoutMode ?? HUDLayoutMode.New));
+
+ case SkinConfiguration.InputOverlayMode:
+ return SkinUtils.As(new Bindable(Configuration.InputOverlayMode ?? InputOverlayMode.Bottom));
+
+ case SkinConfiguration.SongMetadataView:
+ return SkinUtils.As(new Bindable(Configuration.SongMetadataView ?? SongMetadataView.Default));
+ }
+
+ return null;
+ }
+
+ private IBindable? getCustomColour(LegacySkinConfiguration configuration, string lookup)
+ {
+ if (configuration.CustomColours != null &&
+ configuration.CustomColours.TryGetValue(lookup, out Color4 col))
+ return new Bindable(col);
+
+ return null;
+ }
+
+ [CanBeNull]
+ protected virtual Drawable? getResult(HitResult result)
+ {
+ return null;
+ }
+
+ public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT)
+ {
+ float ratio = 2;
+ var texture = Textures?.Get($"{componentName}@2x", wrapModeS, wrapModeT);
+
+ if (texture == null)
+ {
+ ratio = 1;
+ texture = Textures?.Get(componentName, wrapModeS, wrapModeT);
+ }
+
+ if (texture == null && !componentName.EndsWith(@"@2x", StringComparison.Ordinal))
+ {
+ componentName = componentName.Replace(@"@2x", string.Empty);
+
+ string twoTimesFilename = $"{Path.ChangeExtension(componentName, null)}@2x{Path.GetExtension(componentName)}";
+
+ texture = Textures?.Get(twoTimesFilename, wrapModeS, wrapModeT);
+
+ if (texture != null)
+ ratio = 2;
+ }
+
+ texture ??= Textures?.Get(componentName, wrapModeS, wrapModeT);
+
+ if (texture != null)
+ texture.ScaleAdjust = ratio;
+
+ return texture;
+ }
+
+ public override ISample? GetSample(ISampleInfo sampleInfo)
+ {
+ IEnumerable lookupNames;
+
+ if (sampleInfo is HitSampleInfo hitSample)
+ lookupNames = getLegacyLookupNames(hitSample);
+ else
+ {
+ lookupNames = sampleInfo.LookupNames.SelectMany(getFallbackSampleNames);
+ }
+
+ foreach (string lookup in lookupNames)
+ {
+ var sample = Samples?.Get(lookup);
+
+ if (sample != null)
+ {
+ return sample;
+ }
+ }
+
+ return null;
+ }
+
+ private IEnumerable getLegacyLookupNames(HitSampleInfo hitSample)
+ {
+ var lookupNames = hitSample.LookupNames.SelectMany(getFallbackSampleNames);
+
+ if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix))
+ {
+ // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin.
+ // using .EndsWith() is intentional as it ensures parity in all edge cases
+ // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not).
+ lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal));
+ }
+
+ foreach (string l in lookupNames)
+ yield return l;
+
+ // also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort.
+ // going forward specifying banks shall always be required, even for elements that wouldn't require it on stable,
+ // which is why this is done locally here.
+ yield return hitSample.Name;
+ }
+
+ private IEnumerable getFallbackSampleNames(string name)
+ {
+ // May be something like "Gameplay/normal-hitnormal" from lazer.
+ yield return name;
+
+ // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/normal-hitnormal" -> "normal-hitnormal").
+ yield return name.Split('/').Last();
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs
new file mode 100644
index 0000000000..3020823bd1
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs
@@ -0,0 +1,82 @@
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.Input.Events;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Scoring;
+
+namespace osu.Game.Skinning.Scripting
+{
+ ///
+ /// Interface for communication between the game and skin scripts.
+ ///
+ public interface ISkinScriptHost
+ {
+ ///
+ /// Gets the current beatmap.
+ ///
+ IBeatmap CurrentBeatmap { get; }
+
+ ///
+ /// Gets the audio manager for sound playback.
+ ///
+ IAudioManager AudioManager { get; }
+
+ ///
+ /// Gets the current skin.
+ ///
+ ISkin CurrentSkin { get; }
+
+ ///
+ /// Gets the current ruleset info.
+ ///
+ IRulesetInfo CurrentRuleset { get; }
+
+ ///
+ /// Creates a new drawable component of the specified type.
+ ///
+ /// The type of component to create.
+ /// The created component.
+ Drawable CreateComponent(string componentType);
+
+ ///
+ /// Gets a texture from the current skin.
+ ///
+ /// The name of the texture.
+ /// The texture, or null if not found.
+ Texture GetTexture(string name);
+
+ ///
+ /// Gets a sample from the current skin.
+ ///
+ /// The name of the sample.
+ /// The sample, or null if not found.
+ ISample GetSample(string name);
+
+ ///
+ /// Subscribe to a game event.
+ ///
+ /// The name of the event to subscribe to.
+ void SubscribeToEvent(string eventName);
+
+ ///
+ /// Log a message to the osu! log.
+ ///
+ /// The message to log.
+ /// The log level.
+ void Log(string message, LogLevel level = LogLevel.Information);
+ }
+
+ ///
+ /// Log levels for skin script messages.
+ ///
+ public enum LogLevel
+ {
+ Debug,
+ Information,
+ Warning,
+ Error
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs
new file mode 100644
index 0000000000..69265c2fbc
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Input.Events;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Settings;
+using osuTK;
+
+namespace osu.Game.Skinning.Scripting.Overlays
+{
+ public class SkinScriptingSettingsSection : SettingsSection
+ {
+ protected override string Header => "皮肤脚本";
+
+ [Resolved]
+ private SkinManager skinManager { get; set; }
+
+ [Resolved]
+ private DialogOverlay dialogOverlay { get; set; }
+
+ [Resolved]
+ private GameHost host { get; set; }
+
+ [Resolved]
+ private Storage storage { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private SkinScriptingConfig scriptConfig { get; set; }
+
+ private readonly Bindable scriptingEnabled = new Bindable(true);
+ private readonly BindableList allowedScripts = new BindableList();
+ private readonly BindableList blockedScripts = new BindableList();
+
+ private FillFlowContainer scriptListFlow;
+ private OsuButton importButton;
+
+ [BackgroundDependencyLoader] private void load()
+ {
+ if (scriptConfig != null)
+ {
+ scriptConfig.BindWith(SkinScriptingSettings.ScriptingEnabled, scriptingEnabled);
+ scriptConfig.BindWith(SkinScriptingSettings.AllowedScripts, allowedScripts);
+ scriptConfig.BindWith(SkinScriptingSettings.BlockedScripts, blockedScripts);
+ }
+
+ Children = new Drawable[]
+ {
+ new SettingsCheckbox
+ {
+ LabelText = "启用皮肤脚本",
+ TooltipText = "允许皮肤使用Lua脚本来自定义外观和行为",
+ Current = scriptingEnabled
+ },
+ new SettingsButton
+ {
+ Text = "从文件导入脚本",
+ Action = ImportScriptFromFile
+ },
+ new OsuSpriteText
+ {
+ Text = "可用脚本",
+ Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold),
+ Margin = new MarginPadding { Top = 20, Bottom = 10 }
+ },
+ new OsuScrollContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = 300,
+ Child = scriptListFlow = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 5)
+ }
+ }
+ };
+
+ // 监听皮肤变化
+ skinManager.CurrentSkin.BindValueChanged(_ => RefreshScriptList(), true);
+ }
+
+ private void RefreshScriptList()
+ {
+ scriptListFlow.Clear();
+
+ if (skinManager.CurrentSkin.Value is LegacySkin skin)
+ {
+ var scripts = skin.Scripts;
+ if (scripts.Count == 0)
+ {
+ scriptListFlow.Add(new OsuSpriteText
+ {
+ Text = "当前皮肤没有可用的脚本",
+ Font = OsuFont.GetFont(size: 16),
+ Colour = Colours.Gray9
+ });
+ }
+ else
+ {
+ foreach (var script in scripts)
+ {
+ scriptListFlow.Add(new ScriptListItem(script, allowedScripts, blockedScripts));
+ }
+ }
+ }
+ else
+ {
+ scriptListFlow.Add(new OsuSpriteText
+ {
+ Text = "当前皮肤不支持脚本",
+ Font = OsuFont.GetFont(size: 16),
+ Colour = Colours.Gray9
+ });
+ }
+ }
+
+ private void ImportScriptFromFile()
+ {
+ Task.Run(async () =>
+ {
+ try
+ {
+ string[] paths = await host.PickFilesAsync(new FilePickerOptions
+ {
+ Title = "选择Lua脚本文件",
+ FileTypes = new[] { ".lua" }
+ }).ConfigureAwait(false);
+
+ if (paths == null || paths.Length == 0)
+ return;
+
+ Schedule(() =>
+ {
+ foreach (string path in paths)
+ {
+ try
+ {
+ // 获取目标路径(当前皮肤文件夹)
+ if (skinManager.CurrentSkin.Value is not LegacySkin skin || skin.SkinInfo.Files == null)
+ {
+ dialogOverlay.Push(new FileImportFaultDialog("当前皮肤不支持脚本导入"));
+ return;
+ }
+
+ string fileName = Path.GetFileName(path);
+ string destPath = Path.Combine(storage.GetFullPath($"skins/{skin.SkinInfo.ID}/{fileName}"));
+
+ // 复制文件
+ File.Copy(path, destPath, true);
+
+ // 刷新皮肤(重新加载脚本)
+ skinManager.RefreshCurrentSkin();
+
+ // 刷新脚本列表
+ RefreshScriptList();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"导入脚本失败: {ex.Message}");
+ dialogOverlay.Push(new FileImportFaultDialog(ex.Message));
+ }
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "选择脚本文件失败");
+ Schedule(() => dialogOverlay.Push(new FileImportFaultDialog(ex.Message)));
+ }
+ });
+ }
+
+ private class ScriptListItem : CompositeDrawable
+ {
+ private readonly SkinScript script;
+ private readonly BindableList allowedScripts;
+ private readonly BindableList blockedScripts;
+
+ private readonly BindableBool isEnabled = new BindableBool(true);
+
+ public ScriptListItem(SkinScript script, BindableList allowedScripts, BindableList blockedScripts)
+ {
+ this.script = script;
+ this.allowedScripts = allowedScripts;
+ this.blockedScripts = blockedScripts;
+
+ RelativeSizeAxes = Axes.X;
+ AutoSizeAxes = Axes.Y;
+
+ // 根据配置设置初始状态
+ string scriptName = script.ScriptName;
+ if (blockedScripts.Contains(scriptName))
+ isEnabled.Value = false;
+ else if (allowedScripts.Count > 0 && !allowedScripts.Contains(scriptName))
+ isEnabled.Value = false;
+ else
+ isEnabled.Value = true;
+
+ isEnabled.ValueChanged += e =>
+ {
+ if (e.NewValue)
+ {
+ // 启用脚本
+ blockedScripts.Remove(scriptName);
+ if (allowedScripts.Count > 0)
+ allowedScripts.Add(scriptName);
+ }
+ else
+ {
+ // 禁用脚本
+ blockedScripts.Add(scriptName);
+ allowedScripts.Remove(scriptName);
+ }
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ InternalChildren = new Drawable[]
+ {
+ new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Padding = new MarginPadding { Horizontal = 10, Vertical = 5 },
+ Children = new Drawable[]
+ {
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Horizontal,
+ Spacing = new Vector2(10, 0),
+ Children = new Drawable[]
+ {
+ new OsuCheckbox
+ {
+ Current = isEnabled,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ },
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 2),
+ Width = 0.9f,
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = script.ScriptName,
+ Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold)
+ },
+ new OsuSpriteText
+ {
+ Text = $"脚本描述: {script.Description ?? "无描述"}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = colours.Gray9
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ this.FadeColour(Colour4.LightGray, 200);
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ this.FadeColour(Colour4.White, 200);
+ base.OnHoverLost(e);
+ }
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs
new file mode 100644
index 0000000000..52344090db
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs
@@ -0,0 +1,191 @@
+using System;
+using System.IO;
+using MoonSharp.Interpreter;
+using osu.Framework.Graphics;
+using osu.Framework.Input.Events;
+using osu.Game.Scoring;
+
+namespace osu.Game.Skinning.Scripting
+{
+ ///
+ /// Represents a Lua script that can customize skin behavior.
+ ///
+ public class SkinScript : IDisposable
+ {
+ private readonly Script luaScript;
+ private readonly ISkinScriptHost host;
+
+ ///
+ /// Gets the name of the script (usually the filename).
+ ///
+ public string ScriptName { get; }
+
+ ///
+ /// Gets the description of the script, if provided.
+ ///
+ public string Description { get; private set; }
+
+ ///
+ /// Gets a value indicating whether the script is enabled.
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Lua script content.
+ /// The name of the script (usually the filename).
+ /// The host interface for the script.
+ public SkinScript(string scriptContent, string scriptName, ISkinScriptHost host)
+ {
+ this.ScriptName = scriptName;
+ this.host = host;
+
+ // Configure MoonSharp for maximum safety
+ luaScript = new Script(CoreModules.Preset_SoftSandbox);
+
+ // Register our host API with MoonSharp
+ var scriptInterface = new SkinScriptInterface(host);
+ UserData.RegisterType();
+ luaScript.Globals["osu"] = scriptInterface; try
+ {
+ // Execute the script
+ luaScript.DoString(scriptContent);
+
+ // Extract script description if available
+ if (luaScript.Globals.Get("SCRIPT_DESCRIPTION").Type != DataType.Nil)
+ Description = luaScript.Globals.Get("SCRIPT_DESCRIPTION").String;
+ else
+ Description = "No description provided";
+
+ // Call onLoad function if it exists
+ if (luaScript.Globals.Get("onLoad").Type != DataType.Nil)
+ {
+ try
+ {
+ luaScript.Call(luaScript.Globals.Get("onLoad"));
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.onLoad: {ex.Message}", LogLevel.Error);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error loading script {ScriptName}: {ex.Message}", LogLevel.Error);
+ }
+ } ///
+ /// Creates a new skin script from a file.
+ ///
+ /// The path to the Lua script file.
+ /// The host interface for the script.
+ /// A new instance of the class.
+ public static SkinScript FromFile(string filePath, ISkinScriptHost host)
+ {
+ string scriptContent = File.ReadAllText(filePath);
+ string scriptName = Path.GetFileName(filePath);
+ return new SkinScript(scriptContent, scriptName, host);
+ } ///
+ /// Notifies the script that a component has been loaded.
+ ///
+ /// The loaded component.
+ public void NotifyComponentLoaded(Drawable component)
+ {
+ if (!IsEnabled)
+ return;
+
+ try
+ {
+ DynValue result = luaScript.Call(luaScript.Globals.Get("onComponentLoaded"), component);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.onComponentLoaded: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ ///
+ /// Notifies the script of a game event.
+ ///
+ /// The name of the event.
+ /// The event data.
+ public void NotifyGameEvent(string eventName, object data)
+ {
+ if (!IsEnabled)
+ return;
+
+ try
+ {
+ DynValue result = luaScript.Call(luaScript.Globals.Get("onGameEvent"), eventName, data);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.onGameEvent: {ex.Message}", LogLevel.Error);
+ }
+ } ///
+ /// Notifies the script of a judgement result.
+ ///
+ /// The judgement result.
+ public void NotifyJudgement(JudgementResult result)
+ {
+ if (!IsEnabled)
+ return;
+
+ try
+ {
+ DynValue dynResult = luaScript.Call(luaScript.Globals.Get("onJudgement"), result);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.onJudgement: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ ///
+ /// Notifies the script of an input event.
+ ///
+ /// The input event.
+ public void NotifyInputEvent(InputEvent inputEvent)
+ {
+ if (!IsEnabled)
+ return;
+
+ try
+ {
+ DynValue result = luaScript.Call(luaScript.Globals.Get("onInputEvent"), inputEvent);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.onInputEvent: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ ///
+ /// Updates the script.
+ ///
+ public void Update()
+ {
+ if (!IsEnabled)
+ return;
+
+ try
+ {
+ DynValue result = luaScript.Call(luaScript.Globals.Get("update"));
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Error in {ScriptName}.update: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ ///
+ /// Releases all resources used by the script.
+ ///
+ public void Dispose()
+ {
+ // Release any resources held by the script
+ luaScript.Globals.Clear();
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs
new file mode 100644
index 0000000000..44fe173471
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using MoonSharp.Interpreter;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Textures;
+
+namespace osu.Game.Skinning.Scripting
+{
+ ///
+ /// Provides an interface for Lua scripts to interact with the game.
+ ///
+ [MoonSharpUserData]
+ public class SkinScriptInterface
+ {
+ private readonly ISkinScriptHost host;
+ private readonly Dictionary eventHandlers = new Dictionary();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The host interface for the script.
+ public SkinScriptInterface(ISkinScriptHost host)
+ {
+ this.host = host;
+ }
+
+ ///
+ /// Gets the beatmap's title.
+ ///
+ /// The beatmap's title.
+ [MoonSharpVisible(true)]
+ public string GetBeatmapTitle()
+ {
+ return host.CurrentBeatmap?.Metadata?.Title ?? "Unknown";
+ }
+
+ ///
+ /// Gets the beatmap's artist.
+ ///
+ /// The beatmap's artist.
+ [MoonSharpVisible(true)]
+ public string GetBeatmapArtist()
+ {
+ return host.CurrentBeatmap?.Metadata?.Artist ?? "Unknown";
+ }
+
+ ///
+ /// Gets the current ruleset's name.
+ ///
+ /// The ruleset's name.
+ [MoonSharpVisible(true)]
+ public string GetRulesetName()
+ {
+ return host.CurrentRuleset?.Name ?? "Unknown";
+ }
+
+ ///
+ /// Creates a new component of the specified type.
+ ///
+ /// The component type name.
+ /// The created component.
+ [MoonSharpVisible(true)]
+ public object CreateComponent(string componentType)
+ {
+ return host.CreateComponent(componentType);
+ }
+
+ ///
+ /// Gets a texture from the current skin.
+ ///
+ /// The name of the texture.
+ /// The texture, or null if not found.
+ [MoonSharpVisible(true)]
+ public object GetTexture(string name)
+ {
+ return host.GetTexture(name);
+ }
+
+ ///
+ /// Gets a sample from the current skin.
+ ///
+ /// The name of the sample.
+ /// The sample, or null if not found.
+ [MoonSharpVisible(true)]
+ public object GetSample(string name)
+ {
+ return host.GetSample(name);
+ }
+
+ ///
+ /// Plays a sample.
+ ///
+ /// The name of the sample.
+ [MoonSharpVisible(true)]
+ public void PlaySample(string name)
+ {
+ var sample = host.GetSample(name);
+ sample?.Play();
+ }
+
+ ///
+ /// Subscribes to a game event.
+ ///
+ /// The name of the event to subscribe to.
+ [MoonSharpVisible(true)]
+ public void SubscribeToEvent(string eventName)
+ {
+ host.SubscribeToEvent(eventName);
+ }
+
+ ///
+ /// Logs a message to the osu! log.
+ ///
+ /// The message to log.
+ /// The log level (debug, info, warning, error).
+ [MoonSharpVisible(true)]
+ public void Log(string message, string level = "info")
+ {
+ LogLevel logLevel = LogLevel.Information;
+
+ switch (level.ToLower())
+ {
+ case "debug":
+ logLevel = LogLevel.Debug;
+ break;
+ case "warning":
+ logLevel = LogLevel.Warning;
+ break;
+ case "error":
+ logLevel = LogLevel.Error;
+ break;
+ }
+
+ host.Log(message, logLevel);
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs
new file mode 100644
index 0000000000..51170f150b
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs
@@ -0,0 +1,301 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.Input.Events;
+using osu.Framework.Platform;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Scoring;
+
+namespace osu.Game.Skinning.Scripting
+{
+ ///
+ /// Manages skin scripts for the current skin.
+ ///
+ [Cached]
+ public class SkinScriptManager : Component, ISkinScriptHost
+ {
+ private readonly List activeScripts = new List();
+
+ [Resolved]
+ private AudioManager audioManager { get; set; }
+
+ [Resolved]
+ private SkinManager skinManager { get; set; }
+
+ [Resolved]
+ private IBindable beatmap { get; set; }
+
+ [Resolved]
+ private IBindable ruleset { get; set; }
+
+ [Resolved]
+ private Storage storage { get; set; }
+
+ private SkinScriptingConfig scriptingConfig;
+
+ private Bindable scriptingEnabled;
+ private BindableList allowedScripts;
+ private BindableList blockedScripts; ///
+ /// Gets the current beatmap.
+ ///
+ public IBeatmap CurrentBeatmap => beatmap.Value?.Beatmap;
+
+ ///
+ /// Gets the audio manager for sound playback.
+ ///
+ public IAudioManager AudioManager => audioManager;
+
+ ///
+ /// Gets the current skin.
+ ///
+ public ISkin CurrentSkin => skinManager.CurrentSkin.Value;
+
+ ///
+ /// Gets the current ruleset info.
+ ///
+ public IRulesetInfo CurrentRuleset => ruleset.Value;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // Initialize scripting configuration
+ scriptingConfig = new SkinScriptingConfig(storage);
+ scriptingEnabled = scriptingConfig.GetBindable(SkinScriptingSettings.ScriptingEnabled);
+ allowedScripts = scriptingConfig.GetBindable>(SkinScriptingSettings.AllowedScripts).GetBoundCopy();
+ blockedScripts = scriptingConfig.GetBindable>(SkinScriptingSettings.BlockedScripts).GetBoundCopy();
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ // Subscribe to skin changes
+ skinManager.CurrentSkinInfo.BindValueChanged(skinChanged);
+
+ // Subscribe to scripting configuration changes
+ scriptingEnabled.BindValueChanged(_ => updateScriptStates(), true);
+ allowedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true);
+ blockedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true);
+ }
+
+ private void updateScriptStates()
+ {
+ if (!scriptingEnabled.Value)
+ {
+ // Disable all scripts when scripting is disabled
+ foreach (var script in activeScripts)
+ script.IsEnabled = false;
+
+ return;
+ }
+
+ foreach (var script in activeScripts)
+ {
+ string scriptName = script.ScriptName;
+
+ if (blockedScripts.Contains(scriptName))
+ script.IsEnabled = false;
+ else if (allowedScripts.Count > 0)
+ script.IsEnabled = allowedScripts.Contains(scriptName);
+ else
+ script.IsEnabled = true;
+ }
+ }
+
+ private void skinChanged(ValueChangedEvent skin)
+ {
+ // Clear existing scripts
+ foreach (var script in activeScripts)
+ script.Dispose();
+
+ activeScripts.Clear();
+
+ if (scriptingEnabled.Value)
+ {
+ // Load scripts from the new skin
+ loadScriptsFromSkin(skinManager.CurrentSkin.Value);
+ }
+ } private void loadScriptsFromSkin(ISkin skin)
+ {
+ if (skin is Skin skinWithFiles)
+ {
+ // Look for Lua script files
+ foreach (var file in skinWithFiles.Files.Where(f => Path.GetExtension(f.Filename).Equals(".lua", StringComparison.OrdinalIgnoreCase)))
+ {
+ try
+ {
+ using (Stream stream = skinWithFiles.GetStream(file.Filename))
+ using (StreamReader reader = new StreamReader(stream))
+ {
+ string scriptContent = reader.ReadToEnd();
+ SkinScript script = new SkinScript(scriptContent, file.Filename, this);
+
+ // 设置脚本的启用状态
+ string scriptName = file.Filename;
+ if (blockedScripts.Contains(scriptName))
+ script.IsEnabled = false;
+ else if (allowedScripts.Count > 0)
+ script.IsEnabled = allowedScripts.Contains(scriptName);
+ else
+ script.IsEnabled = true;
+
+ activeScripts.Add(script);
+
+ Log($"Loaded skin script: {file.Filename}", LogLevel.Information);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log($"Failed to load skin script {file.Filename}: {ex.Message}", LogLevel.Error);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Notifies scripts that a component has been loaded.
+ ///
+ /// The loaded component.
+ public void NotifyComponentLoaded(Drawable component)
+ {
+ foreach (var script in activeScripts)
+ script.NotifyComponentLoaded(component);
+ }
+
+ ///
+ /// Notifies scripts of a game event.
+ ///
+ /// The name of the event.
+ /// The event data.
+ public void NotifyGameEvent(string eventName, object data)
+ {
+ foreach (var script in activeScripts)
+ script.NotifyGameEvent(eventName, data);
+ }
+
+ ///
+ /// Notifies scripts of a judgement result.
+ ///
+ /// The judgement result.
+ public void NotifyJudgement(JudgementResult result)
+ {
+ foreach (var script in activeScripts)
+ script.NotifyJudgement(result);
+ }
+
+ ///
+ /// Notifies scripts of an input event.
+ ///
+ /// The input event.
+ public void NotifyInputEvent(InputEvent inputEvent)
+ {
+ foreach (var script in activeScripts)
+ script.NotifyInputEvent(inputEvent);
+ }
+
+ ///
+ /// Updates all scripts.
+ ///
+ protected override void Update()
+ {
+ base.Update();
+
+ foreach (var script in activeScripts)
+ script.Update();
+ }
+
+ #region ISkinScriptHost Implementation
+
+ ///
+ /// Creates a new drawable component of the specified type.
+ ///
+ /// The type of component to create.
+ /// The created component.
+ public Drawable CreateComponent(string componentType)
+ {
+ // This would need to be expanded with actual component types
+ switch (componentType)
+ {
+ case "Container":
+ return new Container();
+ // Add more component types as needed
+ default:
+ Log($"Unknown component type: {componentType}", LogLevel.Warning);
+ return new Container();
+ }
+ }
+
+ ///
+ /// Gets a texture from the current skin.
+ ///
+ /// The name of the texture.
+ /// The texture, or null if not found.
+ public Texture GetTexture(string name)
+ {
+ return skinManager.CurrentSkin.Value.GetTexture(name);
+ }
+
+ ///
+ /// Gets a sample from the current skin.
+ ///
+ /// The name of the sample.
+ /// The sample, or null if not found.
+ public ISample GetSample(string name)
+ {
+ return skinManager.CurrentSkin.Value.GetSample(name);
+ }
+
+ ///
+ /// Subscribe to a game event.
+ ///
+ /// The name of the event to subscribe to.
+ public void SubscribeToEvent(string eventName)
+ {
+ // Implementation would depend on available events
+ Log($"Script subscribed to event: {eventName}", LogLevel.Debug);
+ }
+
+ ///
+ /// Log a message to the osu! log.
+ ///
+ /// The message to log.
+ /// The log level.
+ public void Log(string message, LogLevel level = LogLevel.Information)
+ {
+ switch (level)
+ {
+ case LogLevel.Debug:
+ Logger.Log(message, level: LogLevel.Debug);
+ break;
+ case LogLevel.Information:
+ Logger.Log(message);
+ break;
+ case LogLevel.Warning:
+ Logger.Log(message, level: Framework.Logging.LogLevel.Important);
+ break;
+ case LogLevel.Error:
+ Logger.Error(message);
+ break;
+ }
+ }
+
+ #endregion
+
+ protected override void Dispose(bool isDisposing)
+ {
+ foreach (var script in activeScripts)
+ script.Dispose();
+
+ activeScripts.Clear();
+
+ base.Dispose(isDisposing);
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs
new file mode 100644
index 0000000000..24f6bf86fa
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using osu.Framework.Bindables;
+using osu.Game.Configuration;
+
+namespace osu.Game.Skinning.Scripting
+{
+ public class SkinScriptingConfig : IniConfigManager
+ {
+ public SkinScriptingConfig(Storage storage) : base(storage)
+ {
+ }
+
+ protected override void InitialiseDefaults()
+ {
+ base.InitialiseDefaults();
+
+ Set(SkinScriptingSettings.ScriptingEnabled, true);
+ Set(SkinScriptingSettings.AllowedScripts, new List());
+ Set(SkinScriptingSettings.BlockedScripts, new List());
+ }
+ }
+
+ public enum SkinScriptingSettings
+ {
+ // 全局启用/禁用脚本功能
+ ScriptingEnabled,
+
+ // 允许的脚本列表
+ AllowedScripts,
+
+ // 禁止的脚本列表
+ BlockedScripts
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs
new file mode 100644
index 0000000000..953674e3ca
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs
@@ -0,0 +1,25 @@
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Settings;
+using osu.Game.Overlays.Settings.Sections;
+using osu.Game.Skinning.Scripting.Overlays;
+
+namespace osu.Game.Skinning.Scripting
+{
+ ///
+ /// 负责注册皮肤脚本设置到设置界面。
+ ///
+ public class SkinScriptingOverlayRegistration : Component
+ {
+ [Resolved]
+ private SettingsOverlay settingsOverlay { get; set; }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // 皮肤脚本设置部分已经在SkinSection中集成,无需额外操作
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs b/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs
new file mode 100644
index 0000000000..996042d4ed
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs
@@ -0,0 +1,460 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using Newtonsoft.Json;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.TypeExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Framework.Logging;
+using osu.Game.Audio;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Play.HUD;
+using osu.Game.Skinning.Scripting;
+
+namespace osu.Game.Skinning
+{
+ public abstract class Skin : IDisposable, ISkin
+ {
+ private readonly IStorageResourceProvider? resources;
+
+ ///
+ /// A texture store which can be used to perform user file lookups for this skin.
+ ///
+ protected TextureStore? Textures { get; }
+
+ ///
+ /// A sample store which can be used to perform user file lookups for this skin.
+ ///
+ protected ISampleStore? Samples { get; }
+
+ public readonly Live SkinInfo;
+
+ public SkinConfiguration Configuration { get; set; }
+
+ public IDictionary LayoutInfos => layoutInfos;
+
+ private readonly Dictionary layoutInfos =
+ new Dictionary();
+
+ ///
+ /// The list of loaded scripts for this skin.
+ ///
+ public List Scripts { get; private set; } = new List();
+
+ public abstract ISample? GetSample(ISampleInfo sampleInfo);
+
+ public Texture? GetTexture(string componentName) => GetTexture(componentName, default, default);
+
+ public abstract Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT);
+
+ public abstract IBindable? GetConfig(TLookup lookup)
+ where TLookup : notnull
+ where TValue : notnull;
+
+ private readonly ResourceStore store = new ResourceStore();
+
+ public string Name { get; }
+
+ ///
+ /// Construct a new skin.
+ ///
+ /// The skin's metadata. Usually a live realm object.
+ /// Access to game-wide resources.
+ /// An optional fallback store which will be used for file lookups that are not serviced by realm user storage.
+ /// An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini".
+ protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore = null, string configurationFilename = @"skin.ini")
+ {
+ this.resources = resources;
+
+ Name = skin.Name;
+
+ if (resources != null)
+ {
+ SkinInfo = skin.ToLive(resources.RealmAccess);
+
+ store.AddStore(new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess));
+
+ var samples = resources.AudioManager?.GetSampleStore(store);
+
+ if (samples != null)
+ {
+ samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY;
+
+ // osu-stable performs audio lookups in order of wav -> mp3 -> ogg.
+ // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering.
+ samples.AddExtension(@"ogg");
+ }
+
+ Samples = samples;
+ Textures = new TextureStore(resources.Renderer, CreateTextureLoaderStore(resources, store));
+ }
+ else
+ {
+ // Generally only used for tests.
+ SkinInfo = skin.ToLiveUnmanaged();
+ }
+
+ if (fallbackStore != null)
+ store.AddStore(fallbackStore);
+
+ var configurationStream = store.GetStream(configurationFilename);
+
+ if (configurationStream != null)
+ {
+ // stream will be closed after use by LineBufferedReader.
+ ParseConfigurationStream(configurationStream);
+ Debug.Assert(Configuration != null);
+ }
+ else
+ {
+ Configuration = new SkinConfiguration
+ {
+ // generally won't be hit as we always write a `skin.ini` on import, but best be safe than sorry.
+ // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298
+ LegacyVersion = SkinConfiguration.LATEST_VERSION,
+ };
+ }
+
+ // skininfo files may be null for default skin.
+ foreach (GlobalSkinnableContainers skinnableTarget in Enum.GetValues())
+ {
+ string filename = $"{skinnableTarget}.json";
+
+ byte[]? bytes = store?.Get(filename);
+
+ if (bytes == null)
+ continue;
+
+ try
+ {
+ string jsonContent = Encoding.UTF8.GetString(bytes);
+
+ var layoutInfo = parseLayoutInfo(jsonContent, skinnableTarget);
+ if (layoutInfo == null)
+ continue;
+
+ LayoutInfos[skinnableTarget] = layoutInfo;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "Failed to load skin configuration.");
+ }
+ }
+ }
+
+ ///
+ /// Called when skin resources have been loaded.
+ /// This is the place to load any script files.
+ ///
+ protected virtual void LoadComplete()
+ {
+ // Load skin scripts if any
+ LoadScripts();
+ }
+
+ ///
+ /// Loads any script files associated with this skin.
+ ///
+ protected virtual void LoadScripts()
+ {
+ if (!(resources?.RealmAccess?.Realm.Find() is SkinScriptManager scriptManager))
+ return;
+
+ foreach (var file in GetScriptFiles())
+ {
+ try
+ {
+ using (Stream stream = GetStream(file))
+ using (StreamReader reader = new StreamReader(stream))
+ {
+ string scriptContent = reader.ReadToEnd();
+ SkinScript script = new SkinScript(scriptContent, file, scriptManager);
+ Scripts.Add(script);
+
+ Logger.Log($"Loaded skin script: {file}", LogLevel.Information);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"Failed to load skin script {file}");
+ }
+ }
+ }
+
+ ///
+ /// Gets a list of script files in the skin.
+ ///
+ /// A list of script file names.
+ protected virtual IEnumerable GetScriptFiles()
+ {
+ return new string[0];
+ }
+
+ ///
+ /// Gets a stream for the specified file.
+ ///
+ /// The name of the file.
+ /// The stream, or null if the file was not found.
+ protected virtual Stream GetStream(string filename)
+ {
+ return store.GetStream(filename);
+ }
+
+ protected virtual IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage)
+ => new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage));
+
+ protected virtual void ParseConfigurationStream(Stream stream)
+ {
+ using (LineBufferedReader reader = new LineBufferedReader(stream, true))
+ Configuration = new LegacySkinDecoder().Decode(reader);
+ }
+
+ ///
+ /// Remove all stored customisations for the provided target.
+ ///
+ /// The target container to reset.
+ public void ResetDrawableTarget(SkinnableContainer targetContainer)
+ {
+ LayoutInfos.Remove(targetContainer.Lookup.Lookup);
+ }
+
+ ///
+ /// Update serialised information for the provided target.
+ ///
+ /// The target container to serialise to this skin.
+ public void UpdateDrawableTarget(SkinnableContainer targetContainer)
+ {
+ if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Lookup, out var layoutInfo))
+ layoutInfos[targetContainer.Lookup.Lookup] = layoutInfo = new SkinLayoutInfo();
+
+ layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray());
+ }
+
+ public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
+ {
+ switch (lookup)
+ {
+ // This fallback is important for user skins which use SkinnableSprites.
+ case SkinnableSprite.SpriteComponentLookup sprite:
+ return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize);
+
+ case UserSkinComponentLookup userLookup:
+ switch (userLookup.Component)
+ {
+ case GlobalSkinnableContainerLookup containerLookup:
+ // It is important to return null if the user has not configured this yet.
+ // This allows skin transformers the opportunity to provide default components.
+ if (!LayoutInfos.TryGetValue(containerLookup.Lookup, out var layoutInfo)) return null;
+ if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null;
+
+ return new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance())
+ };
+ }
+
+ break;
+ }
+
+ return null;
+ }
+
+ #region Deserialisation & Migration
+
+ private SkinLayoutInfo? parseLayoutInfo(string jsonContent, GlobalSkinnableContainers target)
+ {
+ SkinLayoutInfo? layout = null;
+
+ // handle namespace changes...
+ jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress");
+ jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter");
+ jsonContent = jsonContent.Replace(@"osu.Game.Skinning.LegacyComboCounter", @"osu.Game.Skinning.LegacyDefaultComboCounter");
+ jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter");
+
+ try
+ {
+ // First attempt to deserialise using the new SkinLayoutInfo format
+ layout = JsonConvert.DeserializeObject(jsonContent);
+ }
+ catch
+ {
+ }
+
+ // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list.
+ if (layout == null)
+ {
+ var deserializedContent = JsonConvert.DeserializeObject>(jsonContent);
+ if (deserializedContent == null)
+ return null;
+
+ layout = new SkinLayoutInfo { Version = 0 };
+ layout.Update(null, deserializedContent.ToArray());
+
+ Logger.Log($"Ferrying {deserializedContent.Count()} components in {target} to global section of new {nameof(SkinLayoutInfo)} format");
+ }
+
+ for (int i = layout.Version + 1; i <= SkinLayoutInfo.LATEST_VERSION; i++)
+ applyMigration(layout, target, i);
+
+ layout.Version = SkinLayoutInfo.LATEST_VERSION;
+
+ foreach (var kvp in layout.DrawableInfo.ToArray())
+ {
+ foreach (var di in kvp.Value)
+ {
+ if (!isValidDrawable(di))
+ layout.DrawableInfo[kvp.Key] = kvp.Value.Where(i => i.Type != di.Type).ToArray();
+ }
+ }
+
+ return layout;
+ }
+
+ private bool isValidDrawable(SerialisedDrawableInfo di)
+ {
+ if (!typeof(ISerialisableDrawable).IsAssignableFrom(di.Type))
+ return false;
+
+ foreach (var child in di.Children)
+ {
+ if (!isValidDrawable(child))
+ return false;
+ }
+
+ return true;
+ }
+
+ private void applyMigration(SkinLayoutInfo layout, GlobalSkinnableContainers target, int version)
+ {
+ switch (version)
+ {
+ case 1:
+ {
+ // Combo counters were moved out of the global HUD components into per-ruleset.
+ // This is to allow some rulesets to customise further (ie. mania and catch moving the combo to within their play area).
+ if (target != GlobalSkinnableContainers.MainHUDComponents ||
+ !layout.TryGetDrawableInfo(null, out var globalHUDComponents) ||
+ resources == null)
+ break;
+
+ var comboCounters = globalHUDComponents.Where(c =>
+ c.Type.Name == nameof(LegacyDefaultComboCounter) ||
+ c.Type.Name == nameof(DefaultComboCounter) ||
+ c.Type.Name == nameof(ArgonComboCounter)).ToArray();
+
+ layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray());
+
+ resources.RealmAccess.Run(r =>
+ {
+ foreach (var ruleset in r.All())
+ {
+ layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents)
+ ? rulesetHUDComponents.Concat(comboCounters).ToArray()
+ : comboCounters);
+ }
+ });
+
+ break;
+ }
+ }
+ }
+
+ #endregion
+
+ #region Disposal
+
+ ~Skin()
+ {
+ // required to potentially clean up sample store from audio hierarchy.
+ Dispose(false);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private bool isDisposed;
+
+ protected virtual void Dispose(bool isDisposing)
+ {
+ if (isDisposed)
+ return;
+
+ isDisposed = true;
+
+ foreach (var script in Scripts)
+ script.Dispose();
+
+ Scripts.Clear();
+
+ Textures?.Dispose();
+ Samples?.Dispose();
+
+ store.Dispose();
+ }
+
+ #endregion
+
+ public override string ToString() => $"{GetType().ReadableName()} {{ Name: {Name} }}";
+
+ private static readonly ThreadLocal nested_level = new ThreadLocal(() => 0);
+
+ [Conditional("SKIN_LOOKUP_DEBUG")]
+ internal static void LogLookupDebug(object callingClass, object lookup, LookupDebugType type, [CallerMemberName] string callerMethod = "")
+ {
+ string icon = string.Empty;
+ int level = nested_level.Value;
+
+ switch (type)
+ {
+ case LookupDebugType.Hit:
+ icon = "🟢 hit";
+ break;
+
+ case LookupDebugType.Miss:
+ icon = "🔴 miss";
+ break;
+
+ case LookupDebugType.Enter:
+ nested_level.Value++;
+ break;
+
+ case LookupDebugType.Exit:
+ nested_level.Value--;
+ if (nested_level.Value == 0)
+ Logger.Log(string.Empty);
+ return;
+ }
+
+ string lookupString = lookup.ToString() ?? string.Empty;
+ string callingClassString = callingClass.ToString() ?? string.Empty;
+
+ Logger.Log($"{string.Join(null, Enumerable.Repeat("|-", level))}{callingClassString}.{callerMethod}(lookup: {lookupString}) {icon}");
+ }
+
+ internal enum LookupDebugType
+ {
+ Hit,
+ Miss,
+ Enter,
+ Exit
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs b/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs
new file mode 100644
index 0000000000..fae5e25de9
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs
@@ -0,0 +1,504 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable disable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Rendering;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Framework.Platform;
+using osu.Framework.Threading;
+using osu.Framework.Utils;
+using osu.Game.Audio;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.Overlays.Notifications;
+using osu.Game.Skinning.Scripting;
+using osu.Game.Utils;
+
+namespace osu.Game.Skinning
+{
+ ///
+ /// Handles the storage and retrieval of s.
+ ///
+ ///
+ /// This is also exposed and cached as to allow for any component to potentially have skinning support.
+ /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process.
+ ///
+ public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter
+ {
+ ///
+ /// The default "classic" skin.
+ ///
+ public Skin DefaultClassicSkin { get; }
+
+ private readonly AudioManager audio;
+
+ private readonly Scheduler scheduler;
+
+ private readonly GameHost host;
+
+ private readonly IResourceStore resources;
+
+ public readonly Bindable CurrentSkin = new Bindable();
+
+ public readonly Bindable> CurrentSkinInfo = new Bindable>(ArgonSkin.CreateInfo().ToLiveUnmanaged());
+
+ private readonly SkinImporter skinImporter;
+
+ private readonly LegacySkinExporter skinExporter;
+
+ private readonly IResourceStore userFiles;
+
+ private Skin argonSkin { get; }
+
+ private Skin trianglesSkin { get; }
+
+ private SkinScriptManager scriptManager;
+
+ public override bool PauseImports
+ {
+ get => base.PauseImports;
+ set
+ {
+ base.PauseImports = value;
+ skinImporter.PauseImports = value;
+ }
+ }
+
+ public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler)
+ : base(storage, realm)
+ {
+ this.audio = audio;
+ this.scheduler = scheduler;
+ this.host = host;
+ this.resources = resources;
+
+ userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
+
+ skinImporter = new SkinImporter(storage, realm, this)
+ {
+ PostNotification = obj => PostNotification?.Invoke(obj),
+ };
+
+ var defaultSkins = new[]
+ {
+ DefaultClassicSkin = new DefaultLegacySkin(this),
+ trianglesSkin = new TrianglesSkin(this),
+ argonSkin = new ArgonSkin(this),
+ new ArgonProSkin(this),
+ new Ez2Skin(this),
+ new SbISkin(this),
+ };
+
+ skinExporter = new LegacySkinExporter(storage)
+ {
+ PostNotification = obj => PostNotification?.Invoke(obj)
+ };
+
+ // Initialize the script manager
+ scriptManager = new SkinScriptManager();
+ realm.RegisterCustomObject(scriptManager);
+
+ CurrentSkinInfo.ValueChanged += skin =>
+ {
+ CurrentSkin.Value = getSkin(skin.NewValue);
+ CurrentSkinInfoChanged?.Invoke();
+ };
+
+ try
+ {
+ // Start with non-user skins to ensure they are present.
+ foreach (var skin in defaultSkins)
+ {
+ if (skin.SkinInfo.ID != SkinInfo.ARGON_SKIN && skin.SkinInfo.ID != SkinInfo.TRIANGLES_SKIN)
+ continue;
+
+ // if the user has a modified copy of the default, use it instead.
+ var existingSkin = realm.Run(r => r.All().FirstOrDefault(s => s.ID == skin.SkinInfo.ID));
+
+ if (existingSkin != null)
+ continue;
+
+ realm.Write(r =>
+ {
+ skin.SkinInfo.Protected = true;
+ r.Add(new SkinInfo
+ {
+ ID = skin.SkinInfo.ID,
+ Name = skin.SkinInfo.Name,
+ Creator = skin.SkinInfo.Creator,
+ Protected = true,
+ InstantiationInfo = skin.SkinInfo.InstantiationInfo
+ });
+ });
+ }
+ }
+ catch
+ {
+ // May fail due to incomplete or breaking migrations.
+ }
+ }
+
+ ///
+ /// Returns a list of all usable s. Includes the special default and random skins.
+ ///
+ /// A list of available s.
+ public List> GetAllUsableSkins()
+ {
+ return Realm.Run(r =>
+ {
+ // First display all skins.
+ var instances = r.All()
+ .Where(s => !s.DeletePending)
+ .OrderBy(s => s.Protected)
+ .ThenBy(s => s.Name)
+ .ToList();
+
+ // Then add all default skin entries.
+ var defaultSkins = r.All()
+ .Where(s => s.ID == SkinInfo.ARGON_SKIN || s.ID == SkinInfo.TRIANGLES_SKIN)
+ .ToList();
+
+ foreach (var s in defaultSkins)
+ instances.Insert(instances.FindIndex(s2 => s2.Protected) + defaultSkins.IndexOf(s), s);
+
+ return instances.Distinct().Select(s => r.Find(s.ID)?.ToLive(Realm.Realm)).Where(s => s != null).ToList();
+ });
+ }
+
+ public event Action CurrentSkinInfoChanged;
+
+ public Skin CurrentSkinInfo { get; private set; }
+
+ public void RefreshCurrentSkin() => CurrentSkinInfo.TriggerChange();
+
+ private Skin getSkin(Live skinInfo)
+ {
+ if (skinInfo == null)
+ return null;
+
+ Skin skin;
+
+ try
+ {
+ switch (skinInfo.ID)
+ {
+ case SkinInfo.ARGON_SKIN:
+ skin = argonSkin;
+ break;
+
+ case SkinInfo.TRIANGLES_SKIN:
+ skin = trianglesSkin;
+ break;
+
+ default:
+ skin = skinInfo.CreateInstance(this);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"Unable to load skin \"{skinInfo.ToString()}\"");
+ return DefaultClassicSkin;
+ }
+
+ return skin;
+ }
+
+ public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => GetDrawableComponent(CurrentSkin.Value, lookup);
+
+ public Drawable GetDrawableComponent(ISkin skin, ISkinComponentLookup lookup)
+ {
+ return skin?.GetDrawableComponent(lookup);
+ }
+
+ public ISample GetSample(ISampleInfo sampleInfo) => GetSample(CurrentSkin.Value, sampleInfo);
+
+ public ISample GetSample(ISkin skin, ISampleInfo sampleInfo)
+ {
+ IEnumerable lookupDebug = null;
+
+ if (DebugDisplay.Value || (lookupDebug ??= GetIpcData>("Debug:SkinLookupTypes"))?.Contains(Skin.LookupDebugType.None) != true)
+ Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ var sample = skin?.GetSample(sampleInfo);
+
+ if (sample != null)
+ return sample;
+
+ foreach (var skSource in AllSources)
+ {
+ sample = skSource.GetSample(sampleInfo);
+ if (sample != null)
+ return sample;
+ }
+
+ return null;
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ public Texture GetTexture(string componentName) => GetTexture(CurrentSkin.Value, componentName);
+
+ public Texture GetTexture(ISkin skin, string componentName)
+ {
+ Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ var texture = skin?.GetTexture(componentName);
+
+ if (texture != null)
+ return texture;
+
+ foreach (var skSource in AllSources)
+ {
+ texture = skSource.GetTexture(componentName);
+ if (texture != null)
+ return texture;
+ }
+
+ return null;
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ public IBindable GetConfig(TLookup lookup) => GetConfig(CurrentSkin.Value, lookup);
+
+ public IBindable GetConfig(ISkin skin, TLookup lookup)
+ where TLookup : notnull
+ where TValue : notnull
+ {
+ Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ var bindable = skin?.GetConfig(lookup);
+
+ if (bindable != null)
+ return bindable;
+
+ foreach (var source in AllSources)
+ {
+ bindable = source.GetConfig(lookup);
+ if (bindable != null)
+ return bindable;
+ }
+
+ return null;
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ public IEnumerable AllSources
+ {
+ get
+ {
+ yield return DefaultClassicSkin;
+ }
+ }
+
+ public IEnumerable GetAllConfigs(TLookup lookup)
+ where TLookup : notnull
+ where TValue : notnull
+ {
+ var sources = new List();
+ var items = new List();
+
+ addFromSource(CurrentSkin.Value, this);
+
+ // This is not sane.
+ foreach (var s in AllSources)
+ addFromSource(s, default);
+
+ return items;
+
+ void addFromSource(ISkin source, object lookupFunction)
+ {
+ if (source == null) return;
+
+ if (sources.Contains(source)) return;
+
+ sources.Add(source);
+
+ if (lookupFunction != null)
+ Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ // check for direct value
+ if (source.GetConfig(lookup)?.Value is TValue val)
+ items.Add(val);
+ }
+ finally
+ {
+ if (lookupFunction != null)
+ Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Exit);
+ }
+ }
+ }
+
+ public IBindable FindConfig(params Func>[] lookups)
+ where TValue : notnull
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ return FindConfig(CurrentSkin.Value, lookups) ?? FindConfig(AllSources, lookups);
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ public IBindable FindConfig(ISkin skin, params Func>[] lookups)
+ where TValue : notnull
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ if (skin == null)
+ return null;
+
+ foreach (var l in lookups)
+ {
+ var bindable = l(skin);
+
+ if (bindable != null)
+ return bindable;
+ }
+
+ return null;
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ public IBindable FindConfig(IEnumerable allSources, params Func>[] lookups)
+ where TValue : notnull
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
+
+ try
+ {
+ foreach (var source in allSources)
+ {
+ var bindable = FindConfig(source, lookups);
+
+ if (bindable != null)
+ return bindable;
+ }
+
+ return null;
+ }
+ finally
+ {
+ Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
+ }
+ }
+
+ #region IResourceStorageProvider
+
+ IRenderer IStorageResourceProvider.Renderer => host.Renderer;
+ AudioManager IStorageResourceProvider.AudioManager => audio;
+ IResourceStore IStorageResourceProvider.Resources => resources;
+ IResourceStore IStorageResourceProvider.Files => userFiles;
+ RealmAccess IStorageResourceProvider.RealmAccess => Realm;
+ IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore);
+
+ #endregion
+
+ #region Implementation of IModelImporter
+
+ public Action>> PresentImport
+ {
+ set => skinImporter.PresentImport = value;
+ }
+
+ public Task Import(params string[] paths) => skinImporter.Import(paths);
+
+ public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters);
+
+ public IEnumerable HandledExtensions => skinImporter.HandledExtensions;
+
+ public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) =>
+ skinImporter.Import(notification, tasks, parameters);
+
+ public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) =>
+ skinImporter.ImportAsUpdate(notification, task, original);
+
+ public Task> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model);
+
+ public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
+ skinImporter.Import(task, parameters, cancellationToken);
+
+ public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value);
+
+ public Task ExportSkin(Live skin) => skinExporter.ExportAsync(skin);
+
+ #endregion
+
+ public void Delete([CanBeNull] Expression> filter = null, bool silent = false)
+ {
+ Realm.Run(r =>
+ {
+ var items = r.All()
+ .Where(s => !s.Protected && !s.DeletePending);
+ if (filter != null)
+ items = items.Where(filter);
+
+ // check the removed skin is not the current user choice. if it is, switch back to default.
+ Guid currentUserSkin = CurrentSkinInfo.Value.ID;
+
+ if (items.Any(s => s.ID == currentUserSkin))
+ scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged());
+
+ Delete(items.ToList(), silent);
+ });
+ }
+
+ public void SetSkinFromConfiguration(string guidString)
+ {
+ Live skinInfo = null;
+
+ if (Guid.TryParse(guidString, out var guid))
+ skinInfo = Query(s => s.ID == guid);
+
+ if (skinInfo == null)
+ {
+ if (guid == SkinInfo.CLASSIC_SKIN)
+ skinInfo = DefaultClassicSkin.SkinInfo;
+ }
+
+ CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo;
+ }
+ }
+}
diff --git a/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs b/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs
new file mode 100644
index 0000000000..5f8a599b62
--- /dev/null
+++ b/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs
@@ -0,0 +1,157 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Caching;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Animations;
+using osu.Game.Skinning.Scripting;
+using osuTK;
+
+namespace osu.Game.Skinning
+{
+ ///
+ /// A drawable which can be skinned via an .
+ ///
+ public partial class SkinnableDrawable : SkinReloadableDrawable
+ {
+ ///
+ /// The displayed component.
+ ///
+ public Drawable Drawable { get; private set; } = null!;
+
+ ///
+ /// Whether the drawable component should be centered in available space.
+ /// Defaults to true.
+ ///
+ public bool CentreComponent = true;
+
+ public new Axes AutoSizeAxes
+ {
+ get => base.AutoSizeAxes;
+ set => base.AutoSizeAxes = value;
+ }
+
+ protected readonly ISkinComponentLookup ComponentLookup;
+
+ private readonly ConfineMode confineMode;
+
+ [Resolved(CanBeNull = true)]
+ private SkinScriptManager scriptManager { get; set; }
+
+ ///
+ /// Create a new skinnable drawable.
+ ///
+ /// The namespace-complete resource name for this skinnable element.
+ /// A function to create the default skin implementation of this element.
+ /// How (if at all) the should be resize to fit within our own bounds.
+ public SkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling)
+ : this(lookup, confineMode)
+ {
+ createDefault = defaultImplementation;
+ }
+
+ protected SkinnableDrawable(ISkinComponentLookup lookup, ConfineMode confineMode = ConfineMode.NoScaling)
+ {
+ ComponentLookup = lookup;
+ this.confineMode = confineMode;
+
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ ///
+ /// Seeks to the 0-th frame if the content of this is an .
+ ///
+ public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0);
+
+ private readonly Func? createDefault;
+
+ private readonly Cached scaling = new Cached();
+
+ private bool isDefault;
+
+ protected virtual Drawable CreateDefault(ISkinComponentLookup lookup) => createDefault?.Invoke(lookup) ?? Empty();
+
+ ///
+ /// Whether to apply size restrictions (specified via ) to the default implementation.
+ ///
+ protected virtual bool ApplySizeRestrictionsToDefault => false;
+
+ protected override void SkinChanged(ISkinSource skin)
+ {
+ var retrieved = skin.GetDrawableComponent(ComponentLookup);
+
+ if (retrieved == null)
+ {
+ Drawable = CreateDefault(ComponentLookup);
+ isDefault = true;
+ }
+ else
+ {
+ Drawable = retrieved;
+ isDefault = false;
+ }
+
+ scaling.Invalidate();
+
+ if (CentreComponent)
+ {
+ Drawable.Origin = Anchor.Centre;
+ Drawable.Anchor = Anchor.Centre;
+ }
+
+ InternalChild = Drawable;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ // Notify scripts that a component has been loaded
+ NotifyScriptsOfComponentLoad();
+ }
+
+ private void NotifyScriptsOfComponentLoad()
+ {
+ // Notify the script manager about the component being loaded
+ scriptManager?.NotifyComponentLoaded(Drawable);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (!scaling.IsValid)
+ {
+ try
+ {
+ if (isDefault && !ApplySizeRestrictionsToDefault) return;
+
+ switch (confineMode)
+ {
+ case ConfineMode.ScaleToFit:
+ Drawable.RelativeSizeAxes = Axes.Both;
+ Drawable.Size = Vector2.One;
+ Drawable.Scale = Vector2.One;
+ Drawable.FillMode = FillMode.Fit;
+ break;
+ }
+ }
+ finally
+ {
+ scaling.Validate();
+ }
+ }
+ }
+ }
+
+ public enum ConfineMode
+ {
+ ///
+ /// Don't apply any scaling. This allows the user element to be of any size, exceeding specified bounds.
+ ///
+ NoScaling,
+ ScaleToFit,
+ }
+}
diff --git a/appveyor.yml b/appveyor.yml
new file mode 100644
index 0000000000..ed48a997e8
--- /dev/null
+++ b/appveyor.yml
@@ -0,0 +1,32 @@
+clone_depth: 1
+version: '{branch}-{build}'
+image: Visual Studio 2022
+cache:
+ - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
+
+dotnet_csproj:
+ patch: true
+ file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects
+ version: '0.0.{build}'
+
+before_build:
+ - cmd: dotnet --info # Useful when version mismatch between CI and local
+ - cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects
+ - cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects
+ - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
+
+build:
+ project: osu.sln
+ parallel: true
+ verbosity: minimal
+ publish_nuget: true
+
+after_build:
+ - ps: .\InspectCode.ps1
+
+test:
+ assemblies:
+ except:
+ - '**\*Android*'
+ - '**\*iOS*'
+ - 'build\**\*'
diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml
new file mode 100644
index 0000000000..175c8d0f1b
--- /dev/null
+++ b/appveyor_deploy.yml
@@ -0,0 +1,86 @@
+clone_depth: 1
+version: '{build}'
+image: Visual Studio 2022
+test: off
+skip_non_tags: true
+configuration: Release
+
+environment:
+ matrix:
+ - job_name: osu-game
+ - job_name: osu-ruleset
+ job_depends_on: osu-game
+ - job_name: taiko-ruleset
+ job_depends_on: osu-game
+ - job_name: catch-ruleset
+ job_depends_on: osu-game
+ - job_name: mania-ruleset
+ job_depends_on: osu-game
+ - job_name: templates
+ job_depends_on: osu-game
+
+nuget:
+ project_feed: true
+
+for:
+ -
+ matrix:
+ only:
+ - job_name: osu-game
+ build_script:
+ - cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+ -
+ matrix:
+ only:
+ - job_name: osu-ruleset
+ build_script:
+ - cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+ -
+ matrix:
+ only:
+ - job_name: taiko-ruleset
+ build_script:
+ - cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+ -
+ matrix:
+ only:
+ - job_name: catch-ruleset
+ build_script:
+ - cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+ -
+ matrix:
+ only:
+ - job_name: mania-ruleset
+ build_script:
+ - cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+ -
+ matrix:
+ only:
+ - job_name: templates
+ build_script:
+ - cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj
+ - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
+
+ - cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+ - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
+
+ - cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
+
+artifacts:
+ - path: '**\*.nupkg'
+
+deploy:
+ - provider: Environment
+ name: nuget
diff --git a/osu.Desktop.slnf b/osu.Desktop.slnf
index 606988ccdf..7a092948f0 100644
--- a/osu.Desktop.slnf
+++ b/osu.Desktop.slnf
@@ -1,19 +1,17 @@
-{
+{
"solution": {
"path": "osu.sln",
"projects": [
+ "..\\osu-framework\\osu.Framework\\osu.Framework.csproj",
+ "..\\osu-resources\\osu.Game.Resources\\osu.Game.Resources.csproj",
"osu.Desktop\\osu.Desktop.csproj",
"osu.Game.Benchmarks\\osu.Game.Benchmarks.csproj",
- "osu.Game.Rulesets.Catch.Tests\\osu.Game.Rulesets.Catch.Tests.csproj",
"osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj",
"osu.Game.Rulesets.Mania.Tests\\osu.Game.Rulesets.Mania.Tests.csproj",
"osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj",
- "osu.Game.Rulesets.Osu.Tests\\osu.Game.Rulesets.Osu.Tests.csproj",
"osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj",
- "osu.Game.Rulesets.Taiko.Tests\\osu.Game.Rulesets.Taiko.Tests.csproj",
"osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj",
"osu.Game.Tests\\osu.Game.Tests.csproj",
- "osu.Game.Tournament.Tests\\osu.Game.Tournament.Tests.csproj",
"osu.Game.Tournament\\osu.Game.Tournament.csproj",
"osu.Game\\osu.Game.csproj",
"Templates\\Rulesets\\ruleset-empty\\osu.Game.Rulesets.EmptyFreeform.Tests\\osu.Game.Rulesets.EmptyFreeform.Tests.csproj",
diff --git a/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs b/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs
new file mode 100644
index 0000000000..a7b9eef328
--- /dev/null
+++ b/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs
@@ -0,0 +1,66 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Runtime.Versioning;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Input.Events;
+using osu.Framework.Platform;
+using osuTK.Input;
+using osu.Game.Screens.Play;
+using osu.Game.LAsEzExtensions.Configuration;
+
+namespace osu.Desktop.EzMacOS
+{
+ [SupportedOSPlatform("macos")]
+ public partial class GameplaySpotlightBlocker : Drawable
+ {
+ private Bindable disableCmdSpace = null!;
+ private IBindable localUserPlaying = null!;
+ private IBindable isActive = null!;
+
+ [Resolved]
+ private GameHost host { get; set; } = null!;
+
+ [BackgroundDependencyLoader]
+ private void load(ILocalUserPlayInfo localUserInfo, Ez2ConfigManager ezConfig)
+ {
+ RelativeSizeAxes = Axes.Both;
+ AlwaysPresent = true;
+
+ localUserPlaying = localUserInfo.PlayingState.GetBoundCopy();
+ localUserPlaying.BindValueChanged(_ => updateBlocking());
+
+ isActive = host.IsActive.GetBoundCopy();
+ isActive.BindValueChanged(_ => updateBlocking());
+
+ disableCmdSpace = ezConfig.GetBindable(Ez2Setting.GameplayDisableCmdSpace);
+ disableCmdSpace.BindValueChanged(_ => updateBlocking(), true);
+ }
+
+ private void updateBlocking()
+ {
+ // Block during active gameplay, including breaks; only allow when NotPlaying.
+ bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying;
+
+ if (shouldDisable)
+ host.InputThread.Scheduler.Add(SpotlightKey.Disable);
+ else
+ host.InputThread.Scheduler.Add(SpotlightKey.Enable);
+ }
+
+ public override bool HandleNonPositionalInput => true;
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ // As a fallback for "read input" only, swallow Space when Command is held
+ // to avoid triggering in-game actions while Spotlight is opening.
+ bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying;
+ if (shouldDisable && e.Key == Key.Space && e.SuperPressed)
+ return true; // handled: don't propagate Space to game
+
+ return base.OnKeyDown(e);
+ }
+ }
+}
diff --git a/osu.Desktop/EzMacOS/SpotlightKey.cs b/osu.Desktop/EzMacOS/SpotlightKey.cs
new file mode 100644
index 0000000000..09762ed0ae
--- /dev/null
+++ b/osu.Desktop/EzMacOS/SpotlightKey.cs
@@ -0,0 +1,184 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using osu.Framework.Logging;
+
+namespace osu.Desktop.EzMacOS
+{
+ [SupportedOSPlatform("macos")]
+ public static class SpotlightKey
+ {
+ private static IntPtr eventTap;
+ private static IntPtr runLoopSource;
+ private static IntPtr runLoopMode;
+ private static bool isDisabled;
+ private static EventTapCallback? callbackDelegate;
+
+ private const int kVK_Space = 49;
+ private const ulong kCGEventFlagMaskCommand = 1UL << 20;
+
+ public static void Disable()
+ {
+ if (isDisabled)
+ return;
+
+ try
+ {
+ Logger.Log("Attempting to disable Cmd+Space (Spotlight) via Accessibility tap...", LoggingTarget.Runtime, LogLevel.Debug);
+
+ ulong mask = CGEventMaskBit(CGEventType.KeyDown);
+ callbackDelegate = OnEventTap;
+
+ eventTap = CGEventTapCreate(
+ kCGHIDEventTap,
+ kCGHeadInsertEventTap,
+ kCGEventTapOptionDefault,
+ mask,
+ callbackDelegate,
+ IntPtr.Zero);
+
+ if (eventTap == IntPtr.Zero)
+ {
+ Logger.Log("Failed to create event tap. Ensure Accessibility permission is granted (System Settings → Privacy & Security → Accessibility).", LoggingTarget.Runtime, LogLevel.Error);
+ return;
+ }
+
+ runLoopSource = CFMachPortCreateRunLoopSource(IntPtr.Zero, eventTap, 0);
+ IntPtr runLoop = CFRunLoopGetMain();
+ runLoopMode = CFStringCreateWithCString(IntPtr.Zero, "kCFRunLoopDefaultMode", kCFStringEncodingUTF8);
+ CFRunLoopAddSource(runLoop, runLoopSource, runLoopMode);
+
+ CGEventTapEnable(eventTap, true);
+ isDisabled = true;
+ Logger.Log("Cmd+Space (Spotlight) blocking enabled during gameplay.", LoggingTarget.Runtime, LogLevel.Verbose);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log($"Failed to disable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error);
+ }
+ }
+
+ public static void Enable()
+ {
+ if (!isDisabled)
+ return;
+
+ try
+ {
+ Logger.Log("Re-enabling Cmd+Space (Spotlight)...", LoggingTarget.Runtime, LogLevel.Debug);
+
+ if (eventTap != IntPtr.Zero)
+ {
+ CGEventTapEnable(eventTap, false);
+ CFMachPortInvalidate(eventTap);
+ }
+
+ if (runLoopSource != IntPtr.Zero)
+ {
+ IntPtr runLoop = CFRunLoopGetMain();
+ CFRunLoopRemoveSource(runLoop, runLoopSource, runLoopMode);
+ CFRelease(runLoopSource);
+ }
+
+ if (runLoopMode != IntPtr.Zero)
+ CFRelease(runLoopMode);
+
+ eventTap = IntPtr.Zero;
+ runLoopSource = IntPtr.Zero;
+ runLoopMode = IntPtr.Zero;
+ callbackDelegate = null;
+
+ isDisabled = false;
+ Logger.Log("Cmd+Space (Spotlight) blocking disabled.", LoggingTarget.Runtime, LogLevel.Verbose);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log($"Failed to enable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error);
+ }
+ }
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate IntPtr EventTapCallback(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo);
+
+ private static IntPtr OnEventTap(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo)
+ {
+ try
+ {
+ if (type == CGEventType.KeyDown)
+ {
+ ulong flags = CGEventGetFlags(@event);
+ long keyCode = CGEventGetIntegerValueField(@event, kCGKeyboardEventKeycode);
+
+ if ((flags & kCGEventFlagMaskCommand) != 0 && keyCode == kVK_Space)
+ {
+ Logger.Log("Blocked Cmd+Space via Accessibility tap", LoggingTarget.Runtime, LogLevel.Debug);
+ return IntPtr.Zero; // swallow event
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Log($"Error in event tap: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
+ }
+
+ return @event;
+ }
+
+ private static ulong CGEventMaskBit(CGEventType type) => 1UL << (int)type;
+
+ private const int kCGHIDEventTap = 0; // HID system-wide events
+ private const int kCGHeadInsertEventTap = 0; // Insert at head
+ private const int kCGEventTapOptionDefault = 0; // Listen-only/active
+
+ private const int kCGKeyboardEventKeycode = 9; // Field for keycode
+
+ private const uint kCFStringEncodingUTF8 = 0x08000100;
+
+ #region Native Methods
+
+ [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
+ private static extern IntPtr CGEventTapCreate(int tap, int place, int options, ulong eventsOfInterest, EventTapCallback callback, IntPtr userInfo);
+
+ [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
+ private static extern void CGEventTapEnable(IntPtr tap, bool enable);
+
+ [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
+ private static extern ulong CGEventGetFlags(IntPtr eventRef);
+
+ [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
+ private static extern long CGEventGetIntegerValueField(IntPtr eventRef, int field);
+
+ [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
+ private static extern void CFMachPortInvalidate(IntPtr port);
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern IntPtr CFMachPortCreateRunLoopSource(IntPtr allocator, IntPtr port, Int32 order);
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern IntPtr CFRunLoopGetMain();
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern void CFRunLoopAddSource(IntPtr rl, IntPtr source, IntPtr mode);
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern void CFRunLoopRemoveSource(IntPtr rl, IntPtr source, IntPtr mode);
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, uint encoding);
+
+ [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ private static extern void CFRelease(IntPtr cf);
+
+ #endregion
+
+ private enum CGEventType
+ {
+ KeyDown = 10,
+ KeyUp = 11
+ }
+ }
+}
+
diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs
index 885ee0620e..9755c9f49f 100644
--- a/osu.Desktop/OsuGameDesktop.cs
+++ b/osu.Desktop/OsuGameDesktop.cs
@@ -16,6 +16,7 @@ using osu.Framework;
using osu.Framework.Logging;
using osu.Game.Updater;
using osu.Desktop.Windows;
+using osu.Desktop.EzMacOS;
using osu.Framework.Allocation;
using osu.Game.Configuration;
using osu.Game.IO;
@@ -136,6 +137,9 @@ namespace osu.Desktop
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
+ if (OperatingSystem.IsMacOS())
+ LoadComponentAsync(new GameplaySpotlightBlocker(), Add);
+
LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs
index 612edb2470..94cea8d45d 100644
--- a/osu.Desktop/Program.cs
+++ b/osu.Desktop/Program.cs
@@ -21,9 +21,9 @@ namespace osu.Desktop
public static class Program
{
#if DEBUG
- private const string base_game_name = @"osu-development";
+ private const string base_game_name = @"osu-Ez2Lazer-development";
#else
- private const string base_game_name = @"osu";
+ private const string base_game_name = @"osu-Ez2Lazer";
#endif
private static LegacyTcpIpcProvider? legacyIpc;
diff --git a/osu.Desktop/Properties/launchSettings.json b/osu.Desktop/Properties/launchSettings.json
index 5e768ec9fa..c7cbc0141f 100644
--- a/osu.Desktop/Properties/launchSettings.json
+++ b/osu.Desktop/Properties/launchSettings.json
@@ -1,7 +1,10 @@
{
"profiles": {
"osu! Desktop": {
- "commandName": "Project"
+ "commandName": "Project",
+ "environmentVariables": {
+ "OSU_SDL3": "1"
+ }
},
"osu! Tournament": {
"commandName": "Project",
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index b0c5c953d4..a2e407005e 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -4,10 +4,10 @@
WinExe
true
A free-to-win rhythm game. Rhythm is just a *click* away!
- osu!
- osu!(lazer)
- osu!
- osu!(lazer)
+ Ez2osu!
+ osu!(Ez2lazer)
+ Ez2lazer!
+ osu!(Ez2lazer)
lazer.ico
0.0.0
0.0.0
diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs
new file mode 100644
index 0000000000..e0e348e6b5
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs
@@ -0,0 +1,87 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable disable
+
+using System;
+using System.Linq;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Objects;
+
+namespace osu.Game.Rulesets.Mania.Tests.Analysis
+{
+ public static class HalfHoldBeatmapSets
+ {
+ public static IBeatmap[] CreateTen(int columns = 4, int notesPerColumn = 32)
+ {
+ // use fixed seeds to produce deterministic but different beatmaps
+ return Enumerable.Range(0, 10).Select(i => HalfHoldBeatmapFactory.Create(columns, notesPerColumn, 1000 + i)).ToArray();
+ }
+ }
+
+ public static class HalfHoldBeatmapFactory
+ {
+ ///
+ /// Create a Mania beatmap with the specified number of columns and notes per column.
+ /// Approximately half of the hit objects will be long notes (HoldNote) and half simple notes.
+ ///
+ public static IBeatmap Create(int columns = 4, int notesPerColumn = 32, int seed = 1234)
+ {
+ var beatmap = new ManiaBeatmap(new StageDefinition(columns))
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ Ruleset = new ManiaRuleset().RulesetInfo,
+ Difficulty = new BeatmapDifficulty
+ {
+ DrainRate = 6,
+ OverallDifficulty = 6,
+ ApproachRate = 6,
+ CircleSize = columns
+ }
+ },
+ ControlPointInfo = new ControlPointInfo()
+ };
+
+ var rnd = new Random(seed);
+
+ for (int col = 0; col < columns; col++)
+ {
+ for (int i = 0; i < notesPerColumn; i++)
+ {
+ // spread notes in time with a base spacing and random jitter
+ double baseTime = col * 2000 + i * 300;
+ double jitter = rnd.NextDouble() * 200 - 100; // ±100ms
+ double startTime = baseTime + jitter;
+
+ // randomly decide whether this object is a hold note, target ~50%
+ bool isHold = (i % 2 == 0);
+
+ if (isHold)
+ {
+ var hold = new HoldNote
+ {
+ StartTime = startTime,
+ Column = col,
+ Duration = 200 + rnd.Next(0, 800) // 200-1000ms
+ };
+ beatmap.HitObjects.Add(hold);
+ }
+ else
+ {
+ var note = new Note
+ {
+ StartTime = startTime,
+ Column = col
+ };
+ beatmap.HitObjects.Add(note);
+ }
+ }
+ }
+
+ return beatmap;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs
new file mode 100644
index 0000000000..67bc1a1629
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs
@@ -0,0 +1,1126 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+
+namespace osu.Game.Rulesets.Mania.Tests.Analysis
+{
+ public static class SRCalculatorTunable
+ {
+ // Tunable parameters (defaults mirror original SRCalculator)
+ public static class Tunables
+ {
+ // keyUsage / anchor
+ public static double KeyUsageBaseContribution = 3.75; // original base_contribution
+ public static double KeyUsageDurationCap = 1500; // original clampedDuration cap
+ public static double KeyUsageExtensionDivisor = 150.0; // original extension divisor
+
+ // LN representation
+ public static double LNRepHeadBoost = 1.3;
+ public static double LNRepMidNeg = -0.3;
+ public static double LNRepTailNeg = -1.0;
+ public static double LNRepFallbackBase = 2.5;
+ public static double LNRepFallbackOffsetMult = 0.5;
+
+ // pBar / lnIntegral
+ public static double PBarLnMultiplier = 6 * 0.001; // 0.006 original
+
+ // rBar / jack penalty
+ public static double RBarStrengthBase = 0.08; // original
+ public static double JackPenaltyMultiplier = 35.0; // original
+
+ // final note adjustments
+ public static double FinalLNLenCap = 1000; // original
+ public static double FinalLNToNotesFactor = 0.5; // original multiplication
+ public static double FinalLNLenDivisor = 200.0; // original
+ public static double TotalNotesOffset = 60.0; // original denominator offset
+ public static double FinalScale = 0.975; // original
+ }
+
+ // Public API similar to SRCalculator
+ public static double CalculateSR(IBeatmap beatmap)
+ {
+ return ComputeInternalXxySR(beatmap, 1.0).sr;
+ }
+
+ public static double CalculateSR(IBeatmap beatmap, double clockRate)
+ {
+ return ComputeInternalXxySR(beatmap, clockRate).sr;
+ }
+
+ private static (double sr, Dictionary times) ComputeInternalXxySR(IBeatmap beatmap, double clockRate = 1.0)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ ManiaBeatmap maniaBeatmap = (ManiaBeatmap)beatmap;
+ int keyCount = Math.Max(1, maniaBeatmap.TotalColumns > 0 ? maniaBeatmap.TotalColumns : (int)Math.Round(maniaBeatmap.BeatmapInfo.Difficulty.CircleSize));
+
+ double sr = XxySRCalculateCoreTunable(maniaBeatmap, keyCount, clockRate);
+ stopwatch.Stop();
+
+ var timings = new Dictionary { ["Total"] = stopwatch.ElapsedMilliseconds };
+ return (sr, timings);
+ }
+
+ #region NoteStruct / LNRep
+
+ private readonly record struct NoteStruct
+ {
+ public NoteStruct(int column, int headTime, int tailTime)
+ {
+ Column = column;
+ HeadTime = headTime;
+ TailTime = tailTime;
+ }
+
+ public int Column { get; }
+ public int HeadTime { get; }
+ public int TailTime { get; }
+ public bool IsLongNote => TailTime >= 0 && TailTime > HeadTime;
+ }
+
+ private readonly struct LNRepStruct
+ {
+ public LNRepStruct(int[] points, double[] cumulative, double[] values)
+ {
+ Points = points;
+ Cumulative = cumulative;
+ Values = values;
+ }
+
+ public int[] Points { get; }
+ public double[] Cumulative { get; }
+ public double[] Values { get; }
+ }
+
+ #endregion
+
+ private static readonly Comparison note_comparer = compareNotes;
+
+ // Core: copy of XxySRCalculateCore but using Tunables
+ public static double XxySRCalculateCoreTunable(ManiaBeatmap maniaBeatmap, int keyCount, double clockRate = 1.0)
+ {
+ double[]? cross = CrossMatrixProvider.GetMatrix(keyCount);
+ if (cross == null || cross[0] == -1) throw new NotSupportedException($"Key mode {keyCount}k is not supported by the SR algorithm.");
+
+ int estimatedNotes = maniaBeatmap.HitObjects.Count;
+ if (estimatedNotes == 0) return 0.0;
+
+ var notes = new List(estimatedNotes);
+ var notesByColumn = new List[keyCount];
+ for (int i = 0; i < keyCount; i++) notesByColumn[i] = new List(estimatedNotes / keyCount + 1);
+
+ foreach (var hitObject in maniaBeatmap.HitObjects)
+ {
+ int column = Math.Clamp(hitObject.Column, 0, keyCount - 1);
+ int head = (int)Math.Round(hitObject.StartTime / clockRate);
+ int tail = (int)Math.Round(hitObject.GetEndTime() / clockRate);
+ if ((hitObject as IHasDuration)?.EndTime == null) tail = -1;
+ if (tail <= head) tail = -1;
+ var note = new NoteStruct(column, head, tail);
+ notes.Add(note);
+ notesByColumn[column].Add(note);
+ }
+
+ notes.Sort(note_comparer);
+ foreach (var colNotes in notesByColumn) colNotes.Sort(note_comparer);
+
+ var longNotes = notes.Where(n => n.IsLongNote).ToList();
+ var longNotesByTails = longNotes.OrderBy(n => n.TailTime).ToList();
+
+ double od = maniaBeatmap.BeatmapInfo.Difficulty.OverallDifficulty;
+ double x = computeHitLeniency(od);
+
+ int maxHead = notes.Max(n => n.HeadTime);
+ int maxTail = longNotes.Count > 0 ? longNotes.Max(n => n.TailTime) : maxHead;
+ int totalTime = Math.Max(maxHead, maxTail) + 1;
+
+ (double[] allCorners, double[] baseCorners, double[] aCorners) = buildCorners(totalTime, notes);
+ bool[][] keyUsage = buildKeyUsage(keyCount, totalTime, notes, baseCorners);
+ int[][] activeColumns = deriveActiveColumns(keyUsage);
+
+ double[][] keyUsage400 = buildKeyUsage400_tunable(keyCount, totalTime, notes, baseCorners);
+ double[] anchorBase = computeAnchor(keyCount, keyUsage400, baseCorners);
+
+ LNRepStruct? lnRep = longNotes.Count > 0 ? buildLNRepresentation_tunable(longNotes, totalTime) : null;
+
+ (double[][] deltaKs, double[] jBarBase) = computeJBar(keyCount, totalTime, x, notesByColumn, baseCorners);
+ double[] jBar = interpValues(allCorners, baseCorners, jBarBase);
+
+ double[] xBarBase = computeXBar(keyCount, totalTime, x, notesByColumn, activeColumns, baseCorners, cross);
+ double[] xBar = interpValues(allCorners, baseCorners, xBarBase);
+
+ double[] pBarBase = computePBar_tunable(keyCount, totalTime, x, notes, lnRep, anchorBase, baseCorners);
+ double[] pBar = interpValues(allCorners, baseCorners, pBarBase);
+
+ double[] aBarBase = computeABar(keyCount, totalTime, deltaKs, activeColumns, aCorners, baseCorners);
+ double[] aBar = interpValues(allCorners, aCorners, aBarBase);
+
+ double[] rBarBase = computeRBar_tunable(keyCount, totalTime, x, notesByColumn, longNotesByTails, baseCorners);
+ double[] rBar = interpValues(allCorners, baseCorners, rBarBase);
+
+ (double[] cStep, double[] ksStep) = computeCAndKs(keyCount, notes, keyUsage, baseCorners);
+ double[] cArr = stepInterp(allCorners, baseCorners, cStep);
+ double[] ksArr = stepInterp(allCorners, baseCorners, ksStep);
+
+ double[] gaps = computeGaps(allCorners);
+ double[] effectiveWeights = new double[allCorners.Length];
+ for (int i = 0; i < allCorners.Length; i++) effectiveWeights[i] = cArr[i] * gaps[i];
+
+ double[] dAll = new double[allCorners.Length];
+
+ Parallel.For(0, allCorners.Length, i =>
+ {
+ double abarExponent = 3.0 / Math.Max(ksArr[i], 1e-6);
+ double abarPow = aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], abarExponent);
+ double minCandidateContribution = 0.85 * jBar[i];
+ double minCandidate = 8 + minCandidateContribution;
+ double minJ = Math.Min(jBar[i], minCandidate);
+ double jackComponent = abarPow * minJ;
+ double term1 = 0.4 * (jackComponent <= 0 ? 0 : Math.Pow(jackComponent, 1.5));
+
+ double scaledP = 0.8 * pBar[i];
+ double jackPenalty = rBar[i] * Tunables.JackPenaltyMultiplier;
+ double ratio = jackPenalty / (cArr[i] + 8);
+ double pComponent = scaledP + ratio;
+ double powerBase = (aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], 2.0 / 3.0)) * pComponent;
+ double term2 = 0.6 * (powerBase <= 0 ? 0 : Math.Pow(powerBase, 1.5));
+
+ double sumTerms = term1 + term2;
+ double s = sumTerms <= 0 ? 0 : Math.Pow(sumTerms, 2.0 / 3.0);
+ double numerator = abarPow * xBar[i];
+ double denominator = xBar[i] + s + 1;
+ double tValue = denominator <= 0 ? 0 : numerator / denominator;
+ double sqrtComponent = Math.Sqrt(Math.Max(s, 0));
+ double primaryImpact = 2.7 * sqrtComponent * (tValue <= 0 ? 0 : Math.Pow(tValue, 1.5));
+ double secondaryImpact = s * 0.27;
+
+ dAll[i] = primaryImpact + secondaryImpact;
+ });
+
+ double sr = finaliseDifficulty_tunable(dAll.ToList(), effectiveWeights.ToList(), notes, longNotes);
+ return sr;
+ }
+
+ #region Tunable helpers (variants of original methods)
+
+ private static double[][] buildKeyUsage400_tunable(int keyCount, int totalTime, List notes, double[] baseCorners)
+ {
+ double[][] usage = new double[keyCount][];
+ for (int k = 0; k < keyCount; k++) usage[k] = new double[baseCorners.Length];
+
+ double base_contribution = Tunables.KeyUsageBaseContribution;
+ double falloff = Tunables.KeyUsageBaseContribution / (400.0 * 400.0);
+
+ foreach (var note in notes)
+ {
+ int startTime = Math.Max(note.HeadTime, 0);
+ int endTime = note.IsLongNote ? Math.Min(note.TailTime, totalTime - 1) : note.HeadTime;
+
+ int left400 = lowerBound(baseCorners, startTime - 400);
+ int left = lowerBound(baseCorners, startTime);
+ int right = lowerBound(baseCorners, endTime);
+ int right400 = lowerBound(baseCorners, endTime + 400);
+
+ int duration = endTime - startTime;
+ double clampedDuration = Math.Min(duration, Tunables.KeyUsageDurationCap);
+ double extension = clampedDuration / Tunables.KeyUsageExtensionDivisor;
+ double contribution = base_contribution + extension;
+
+ for (int idx = left; idx < right; idx++) usage[note.Column][idx] += contribution;
+
+ for (int idx = left400; idx < left; idx++)
+ {
+ double offset = baseCorners[idx] - startTime;
+ double falloffContribution = falloff * Math.Pow(offset, 2);
+ double value = base_contribution - falloffContribution;
+ double clamped = Math.Max(value, 0);
+ usage[note.Column][idx] += clamped;
+ }
+
+ for (int idx = right; idx < right400; idx++)
+ {
+ double offset = baseCorners[idx] - endTime;
+ double falloffContribution = falloff * Math.Pow(offset, 2);
+ double value = base_contribution - falloffContribution;
+ double clamped = Math.Max(value, 0);
+ usage[note.Column][idx] += clamped;
+ }
+ }
+
+ return usage;
+ }
+
+ private static double[] computeAnchor(int keyCount, double[][] keyUsage400, double[] baseCorners)
+ {
+ double[] anchor = new double[baseCorners.Length];
+
+ for (int i = 0; i < baseCorners.Length; i++)
+ {
+ double[] counts = new double[keyCount];
+ for (int k = 0; k < keyCount; k++)
+ counts[k] = keyUsage400[k][i];
+
+ Array.Sort(counts);
+ Array.Reverse(counts);
+
+ double[] nonZero = counts.Where(c => c > 0).ToArray();
+
+ if (nonZero.Length <= 1)
+ {
+ anchor[i] = 0;
+ continue;
+ }
+
+ double walk = 0;
+ double maxWalk = 0;
+
+ for (int idx = 0; idx < nonZero.Length - 1; idx++)
+ {
+ double current = nonZero[idx];
+ double next = nonZero[idx + 1];
+ double ratio = next / current;
+ double offset = 0.5 - ratio;
+ double offsetPenalty = 4 * Math.Pow(offset, 2);
+ double damping = 1 - offsetPenalty;
+ walk += current * damping;
+ maxWalk += current;
+ }
+
+ double value = maxWalk <= 0 ? 0 : walk / maxWalk;
+ anchor[i] = 1 + Math.Min(value - 0.18, 5 * Math.Pow(value - 0.22, 3));
+ }
+
+ return anchor;
+ }
+
+ private static LNRepStruct buildLNRepresentation_tunable(List longNotes, int totalTime)
+ {
+ var diff = new Dictionary();
+
+ foreach (var note in longNotes)
+ {
+ int t0 = Math.Min(note.HeadTime + 60, note.TailTime);
+ int t1 = Math.Min(note.HeadTime + 120, note.TailTime);
+
+ addToMap(diff, t0, Tunables.LNRepHeadBoost);
+ addToMap(diff, t1, Tunables.LNRepMidNeg);
+ addToMap(diff, note.TailTime, Tunables.LNRepTailNeg);
+ }
+
+ var pointsSet = new SortedSet { 0, totalTime };
+ foreach (int key in diff.Keys) pointsSet.Add(key);
+
+ int[] points = pointsSet.ToArray();
+ double[] cumulative = new double[points.Length];
+ double[] values = new double[points.Length - 1];
+
+ double current = 0;
+
+ for (int i = 0; i < points.Length - 1; i++)
+ {
+ if (diff.TryGetValue(points[i], out double delta)) current += delta;
+
+ double fallbackOffset = Tunables.LNRepFallbackOffsetMult * current;
+ double fallback = Tunables.LNRepFallbackBase + fallbackOffset;
+ double transformed = Math.Min(current, fallback);
+ values[i] = transformed;
+
+ int length = points[i + 1] - points[i];
+ double segment = length * transformed;
+ cumulative[i + 1] = cumulative[i] + segment;
+ }
+
+ return new LNRepStruct(points, cumulative, values);
+ }
+
+ private static double[] computePBar_tunable(int keyCount, int totalTime, double x, List notes, LNRepStruct? lnRep, double[] anchor, double[] baseCorners)
+ {
+ double[] pStep = new double[baseCorners.Length];
+
+ for (int i = 0; i < notes.Count - 1; i++)
+ {
+ var leftNote = notes[i];
+ var rightNote = notes[i + 1];
+ int deltaTime = rightNote.HeadTime - leftNote.HeadTime;
+
+ if (deltaTime <= 0)
+ {
+ double invX = 1.0 / Math.Max(x, 1e-6);
+ double spikeInnerBase = 4 * invX;
+ double spikeInner = spikeInnerBase - 24;
+ double spikeBase = 0.02 * spikeInner;
+ if (spikeBase <= 0) continue;
+
+ double spikeMagnitude = Math.Pow(spikeBase, 0.25);
+ double spike = 1000 * spikeMagnitude;
+ int leftIdx = lowerBound(baseCorners, leftNote.HeadTime);
+ int rightIdx = upperBound(baseCorners, leftNote.HeadTime);
+ for (int idx = leftIdx; idx < rightIdx; idx++) pStep[idx] += spike;
+ continue;
+ }
+
+ int left = lowerBound(baseCorners, leftNote.HeadTime);
+ int right = lowerBound(baseCorners, rightNote.HeadTime);
+ if (right <= left) continue;
+
+ double delta = 0.001 * deltaTime;
+ double v = 1;
+ if (lnRep.HasValue) v += Tunables.PBarLnMultiplier * lnIntegral(lnRep.Value, leftNote.HeadTime, rightNote.HeadTime);
+
+ double booster = streamBooster(delta);
+ double effective = Math.Max(booster, v);
+
+ double inc;
+
+ if (delta < 2 * x / 3)
+ {
+ double invX = 1.0 / Math.Max(x, 1e-6);
+ double halfX = x / 2.0;
+ double deltaCentre = delta - halfX;
+ double deltaTerm = 24 * invX * Math.Pow(deltaCentre, 2);
+ double inner = 0.08 * invX * (1 - deltaTerm);
+ double innerClamp = Math.Max(inner, 0);
+ double magnitude = Math.Pow(innerClamp, 0.25);
+ inc = magnitude / Math.Max(delta, 1e-6) * effective;
+ }
+ else
+ {
+ double invX = 1.0 / Math.Max(x, 1e-6);
+ double centreTerm = Math.Pow(x / 6.0, 2);
+ double deltaTerm = 24 * invX * centreTerm;
+ double inner = 0.08 * invX * (1 - deltaTerm);
+ double innerClamp = Math.Max(inner, 0);
+ double magnitude = Math.Pow(innerClamp, 0.25);
+ inc = magnitude / Math.Max(delta, 1e-6) * effective;
+ }
+
+ for (int idx = left; idx < right; idx++)
+ {
+ double doubled = inc * 2;
+ double limit = Math.Max(inc, doubled - 10);
+ double anchored = inc * anchor[idx];
+ double contribution = Math.Min(anchored, limit);
+ pStep[idx] += contribution;
+ }
+ }
+
+ return SmoothOnCorners(baseCorners, pStep, 500, 0.001, SmoothMode.Sum);
+ }
+
+ private static double[] computeRBar_tunable(int keyCount, int totalTime, double x, List[] notesByColumn, List tailNotes, double[] baseCorners)
+ {
+ if (tailNotes.Count < 2) return new double[baseCorners.Length];
+
+ double[] iList = new double[tailNotes.Count];
+
+ for (int idx = 0; idx < tailNotes.Count; idx++)
+ {
+ var note = tailNotes[idx];
+ var next = findNextColumnNote(note, notesByColumn);
+ double nextHead = next?.HeadTime ?? 1_000_000_000;
+
+ double ih = 0.001 * Math.Abs(note.TailTime - note.HeadTime - 80) / Math.Max(x, 1e-6);
+ double it = 0.001 * Math.Abs(nextHead - note.TailTime - 80) / Math.Max(x, 1e-6);
+
+ iList[idx] = 2 / (2 + Math.Exp(-5 * (ih - 0.75)) + Math.Exp(-5 * (it - 0.75)));
+ }
+
+ double[] rStep = new double[baseCorners.Length];
+
+ for (int idx = 0; idx < tailNotes.Count - 1; idx++)
+ {
+ var current = tailNotes[idx];
+ var next = tailNotes[idx + 1];
+ int left = lowerBound(baseCorners, current.TailTime);
+ int right = lowerBound(baseCorners, next.TailTime);
+ if (right <= left) continue;
+
+ double delta = 0.001 * Math.Max(next.TailTime - current.TailTime, 1e-6);
+ double invSqrtDelta = Math.Pow(delta, -0.5);
+ double invX = 1.0 / Math.Max(x, 1e-6);
+ double blend = iList[idx] + iList[idx + 1];
+ double blendContribution = 0.8 * blend;
+ double modulation = 1 + blendContribution;
+ double strength = Tunables.RBarStrengthBase * invSqrtDelta * invX * modulation;
+
+ for (int baseIdx = left; baseIdx < right; baseIdx++) rStep[baseIdx] = Math.Max(rStep[baseIdx], strength);
+ }
+
+ return SmoothOnCorners(baseCorners, rStep, 500, 0.001, SmoothMode.Sum);
+ }
+
+ private static double finaliseDifficulty_tunable(List difficulties, List weights, List notes, List longNotes)
+ {
+ // reuse original finaliseDifficulty logic but replace LN-related constants
+ var combined = difficulties.Zip(weights, (d, w) => (d, w)).OrderBy(pair => pair.d).ToList();
+ if (combined.Count == 0) return 0;
+
+ double[] sortedD = combined.Select(p => p.d).ToArray();
+ double[] sortedWeights = combined.Select(p => Math.Max(p.w, 0)).ToArray();
+
+ double[] cumulative = new double[sortedWeights.Length];
+ cumulative[0] = sortedWeights[0];
+ for (int i = 1; i < sortedWeights.Length; i++) cumulative[i] = cumulative[i - 1] + sortedWeights[i];
+
+ double totalWeight = Math.Max(cumulative[^1], 1e-9);
+ double[] norm = cumulative.Select(v => v / totalWeight).ToArray();
+
+ double[] targets = { 0.945, 0.935, 0.925, 0.915, 0.845, 0.835, 0.825, 0.815 };
+ double percentile93 = 0;
+ double percentile83 = 0;
+
+ for (int i = 0; i < 4; i++)
+ {
+ int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1);
+ percentile93 += sortedD[index];
+ }
+
+ percentile93 /= 4.0;
+
+ for (int i = 4; i < 8; i++)
+ {
+ int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1);
+ percentile83 += sortedD[index];
+ }
+
+ percentile83 /= 4.0;
+
+ double weightedMeanNumerator = 0;
+ for (int i = 0; i < sortedD.Length; i++) weightedMeanNumerator += Math.Pow(sortedD[i], 5) * sortedWeights[i];
+ double weightedMean = Math.Pow(Math.Max(weightedMeanNumerator / totalWeight, 0), 0.2);
+
+ double topComponent = 0.25 * 0.88 * percentile93;
+ double middleComponent = 0.2 * 0.94 * percentile83;
+ double meanComponent = 0.55 * weightedMean;
+ double sr = topComponent + middleComponent + meanComponent;
+ sr = Math.Pow(sr, 1.0) / Math.Pow(8, 1.0) * 8;
+
+ double totalNotes = notes.Count;
+
+ foreach (var ln in longNotes)
+ {
+ double len = Math.Min(ln.TailTime - ln.HeadTime, Tunables.FinalLNLenCap);
+ totalNotes += Tunables.FinalLNToNotesFactor * (len / Tunables.FinalLNLenDivisor);
+ }
+
+ sr *= totalNotes / (totalNotes + Tunables.TotalNotesOffset);
+ sr = rescaleHigh(sr);
+ sr *= Tunables.FinalScale;
+
+ return sr;
+ }
+
+ #endregion
+
+ #region Reused helper methods (copied from original)
+
+ private static double computeHitLeniency(double overallDifficulty)
+ {
+ double leniency = 0.3 * Math.Sqrt((64.5 - Math.Ceiling(overallDifficulty * 3.0)) / 500.0);
+ double offset = leniency - 0.09;
+ double scaledOffset = 0.6 * offset;
+ double adjustedWindow = scaledOffset + 0.09;
+ return Math.Min(leniency, adjustedWindow);
+ }
+
+ private static (double[] allCorners, double[] baseCorners, double[] aCorners) buildCorners(int totalTime, List notes)
+ {
+ var baseSet = new HashSet();
+
+ foreach (var note in notes)
+ {
+ baseSet.Add(note.HeadTime);
+ if (note.IsLongNote) baseSet.Add(note.TailTime);
+ }
+
+ foreach (int value in baseSet.ToArray())
+ {
+ baseSet.Add(value + 501);
+ baseSet.Add(value - 499);
+ baseSet.Add(value + 1);
+ }
+
+ baseSet.Add(0);
+ baseSet.Add(totalTime);
+ double[] baseCorners = baseSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray();
+
+ var aSet = new HashSet();
+
+ foreach (var note in notes)
+ {
+ aSet.Add(note.HeadTime);
+ if (note.IsLongNote) aSet.Add(note.TailTime);
+ }
+
+ foreach (int value in aSet.ToArray())
+ {
+ aSet.Add(value + 1000);
+ aSet.Add(value - 1000);
+ }
+
+ aSet.Add(0);
+ aSet.Add(totalTime);
+ double[] aCorners = aSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray();
+
+ double[] allCorners = baseCorners.Concat(aCorners).Distinct().OrderBy(v => v).ToArray();
+ return (allCorners, baseCorners, aCorners);
+ }
+
+ private static bool[][] buildKeyUsage(int keyCount, int totalTime, List notes, double[] baseCorners)
+ {
+ bool[][] keyUsage = new bool[keyCount][];
+ for (int i = 0; i < keyCount; i++) keyUsage[i] = new bool[baseCorners.Length];
+
+ foreach (var note in notes)
+ {
+ int start = Math.Max(note.HeadTime - 150, 0);
+ int end = note.IsLongNote ? Math.Min(note.TailTime + 150, totalTime - 1) : Math.Min(note.HeadTime + 150, totalTime - 1);
+ int left = lowerBound(baseCorners, start);
+ int right = lowerBound(baseCorners, end);
+ for (int idx = left; idx < right; idx++) keyUsage[note.Column][idx] = true;
+ }
+
+ return keyUsage;
+ }
+
+ private static int[][] deriveActiveColumns(bool[][] keyUsage)
+ {
+ int length = keyUsage[0].Length;
+ int[][] active = new int[length][];
+
+ for (int i = 0; i < length; i++)
+ {
+ var list = new List();
+
+ for (int col = 0; col < keyUsage.Length; col++)
+ {
+ if (keyUsage[col][i])
+ list.Add(col);
+ }
+
+ active[i] = list.ToArray();
+ }
+
+ return active;
+ }
+
+ private static (double[][] deltaKs, double[] jBar) computeJBar(int keyCount, int totalTime, double x, List[] notesByColumn, double[] baseCorners)
+ {
+ const double default_delta = 1e9;
+ double[][] deltaKs = new double[keyCount][];
+ double[][] jKs = new double[keyCount][];
+ Parallel.For(0, keyCount, k =>
+ {
+ deltaKs[k] = Enumerable.Repeat(default_delta, baseCorners.Length).ToArray();
+ jKs[k] = new double[baseCorners.Length];
+ var columnNotes = notesByColumn[k];
+
+ for (int i = 0; i < columnNotes.Count - 1; i++)
+ {
+ var current = columnNotes[i];
+ var next = columnNotes[i + 1];
+ int left = lowerBound(baseCorners, current.HeadTime);
+ int right = lowerBound(baseCorners, next.HeadTime);
+ if (right <= left) continue;
+
+ double headGap = Math.Max(next.HeadTime - current.HeadTime, 1e-6);
+ double delta = 0.001 * headGap;
+ double deltaShift = Math.Abs(delta - 0.08);
+ double penalty = 0.15 + deltaShift;
+ double attenuation = Math.Pow(penalty, -4);
+ double nerfFactor = 7e-5 * attenuation;
+ double jackNerfer = 1 - nerfFactor;
+ double xRoot = Math.Pow(x, 0.25);
+ double rootScale = 0.11 * xRoot;
+ double jackBase = delta + rootScale;
+ double inverseJack = Math.Pow(jackBase, -1);
+ double inverseDelta = 1.0 / delta;
+ double value = inverseDelta * inverseJack * jackNerfer;
+
+ for (int idx = left; idx < right; idx++)
+ {
+ deltaKs[k][idx] = Math.Min(deltaKs[k][idx], delta);
+ jKs[k][idx] = value;
+ }
+ }
+
+ jKs[k] = SmoothOnCorners(baseCorners, jKs[k], 500, 0.001, SmoothMode.Sum);
+ });
+
+ double[] jBar = new double[baseCorners.Length];
+
+ for (int idx = 0; idx < baseCorners.Length; idx++)
+ {
+ double numerator = 0;
+ double denominator = 0;
+
+ for (int k = 0; k < keyCount; k++)
+ {
+ double v = Math.Max(jKs[k][idx], 0);
+ double weight = 1.0 / Math.Max(deltaKs[k][idx], 1e-9);
+ numerator += Math.Pow(v, 5) * weight;
+ denominator += weight;
+ }
+
+ double combined = denominator <= 0 ? 0 : numerator / denominator;
+ jBar[idx] = Math.Pow(Math.Max(combined, 0), 0.2);
+ }
+
+ return (deltaKs, jBar);
+ }
+
+ private static double[] computeXBar(int keyCount, int totalTime, double x, List[] notesByColumn, int[][] activeColumns, double[] baseCorners, double[] cross)
+ {
+ double[][] xKs = new double[keyCount + 1][];
+ double[][] fastCross = new double[keyCount + 1][];
+
+ for (int i = 0; i < xKs.Length; i++)
+ {
+ xKs[i] = new double[baseCorners.Length];
+ fastCross[i] = new double[baseCorners.Length];
+ }
+
+ Parallel.For(0, keyCount + 1, k =>
+ {
+ var pair = new List();
+
+ if (k == 0) pair.AddRange(notesByColumn[0]);
+ else if (k == keyCount) pair.AddRange(notesByColumn[keyCount - 1]);
+ else
+ {
+ pair.AddRange(notesByColumn[k - 1]);
+ pair.AddRange(notesByColumn[k]);
+ }
+
+ pair.Sort(note_comparer);
+ if (pair.Count < 2) return;
+
+ for (int i = 1; i < pair.Count; i++)
+ {
+ var prev = pair[i - 1];
+ var current = pair[i];
+ int left = lowerBound(baseCorners, prev.HeadTime);
+ int right = lowerBound(baseCorners, current.HeadTime);
+ if (right <= left) continue;
+
+ double delta = 0.001 * Math.Max(current.HeadTime - prev.HeadTime, 1e-6);
+ double val = 0.16 * Math.Pow(Math.Max(x, delta), -2);
+ int idxStart = Math.Min(left, baseCorners.Length - 1);
+ int idxEnd = Math.Min(Math.Max(right, 0), baseCorners.Length - 1);
+ bool condition1 = !contains(activeColumns[idxStart], k - 1) && !contains(activeColumns[idxEnd], k - 1);
+ bool condition2 = !contains(activeColumns[idxStart], k) && !contains(activeColumns[idxEnd], k);
+ if (condition1 || condition2) val *= 1 - cross[Math.Min(k, cross.Length - 1)];
+
+ for (int idx = left; idx < right; idx++)
+ {
+ xKs[k][idx] = val;
+ fastCross[k][idx] = Math.Max(0, 0.4 * Math.Pow(Math.Max(Math.Max(delta, 0.06), 0.75 * x), -2) - 80);
+ }
+ }
+ });
+
+ double[] xBase = new double[baseCorners.Length];
+
+ for (int idx = 0; idx < baseCorners.Length; idx++)
+ {
+ double sum = 0;
+ for (int k = 0; k <= keyCount; k++) sum += cross[Math.Min(k, cross.Length - 1)] * xKs[k][idx];
+
+ for (int k = 0; k < keyCount; k++)
+ {
+ double leftVal = fastCross[k][idx] * cross[Math.Min(k, cross.Length - 1)];
+ double rightVal = fastCross[k + 1][idx] * cross[Math.Min(k + 1, cross.Length - 1)];
+ sum += Math.Sqrt(Math.Max(leftVal * rightVal, 0));
+ }
+
+ xBase[idx] = sum;
+ }
+
+ return SmoothOnCorners(baseCorners, xBase, 500, 0.001, SmoothMode.Sum);
+ }
+
+ // many helper methods reused without modification
+ private static double lnIntegral(LNRepStruct repStruct, int a, int b)
+ {
+ int[] points = repStruct.Points;
+ double[] cumulative = repStruct.Cumulative;
+ double[] values = repStruct.Values;
+ int startIndex = upperBound(points, a) - 1;
+ int endIndex = upperBound(points, b) - 1;
+ if (startIndex < 0) startIndex = 0;
+ if (endIndex < startIndex) endIndex = startIndex;
+ double total = 0;
+
+ if (startIndex == endIndex) total = (b - a) * values[startIndex];
+ else
+ {
+ total += (points[startIndex + 1] - a) * values[startIndex];
+ total += cumulative[endIndex] - cumulative[startIndex + 1];
+ total += (b - points[endIndex]) * values[endIndex];
+ }
+
+ return total;
+ }
+
+ private static double[] computeABar(int keyCount, int totalTime, double[][] deltaKs, int[][] activeColumns, double[] aCorners, double[] baseCorners)
+ {
+ double[] aStep = Enumerable.Repeat(1.0, aCorners.Length).ToArray();
+
+ for (int i = 0; i < aCorners.Length; i++)
+ {
+ int idx = lowerBound(baseCorners, aCorners[i]);
+ idx = Math.Min(idx, baseCorners.Length - 1);
+ int[] cols = activeColumns[idx];
+ if (cols.Length < 2) continue;
+
+ for (int j = 0; j < cols.Length - 1; j++)
+ {
+ int c0 = cols[j];
+ int c1 = cols[j + 1];
+ double deltaGap = Math.Abs(deltaKs[c0][idx] - deltaKs[c1][idx]);
+ double maxDelta = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
+ double offset = Math.Max(maxDelta - 0.11, 0);
+ double offsetContribution = 0.4 * offset;
+ double diff = deltaGap + offsetContribution;
+
+ if (diff < 0.02)
+ {
+ double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
+ double factorContribution = 0.5 * factorBase;
+ double factor = 0.75 + factorContribution;
+ aStep[i] *= Math.Min(factor, 1);
+ }
+ else if (diff < 0.07)
+ {
+ double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
+ double growth = 5 * diff;
+ double factorContribution = 0.5 * factorBase;
+ double factor = 0.65 + growth + factorContribution;
+ aStep[i] *= Math.Min(factor, 1);
+ }
+ }
+ }
+
+ return SmoothOnCorners(aCorners, aStep, 250, 0, SmoothMode.Average);
+ }
+
+ private static (double[] cStep, double[] ksStep) computeCAndKs(int keyCount, List notes, bool[][] keyUsage, double[] baseCorners)
+ {
+ double[] cStep = new double[baseCorners.Length];
+ double[] ksStep = new double[baseCorners.Length];
+ var noteTimesList = new List(notes.Count);
+ foreach (var note in notes) noteTimesList.Add(note.HeadTime);
+ noteTimesList.Sort();
+ double[] noteTimes = noteTimesList.ToArray();
+
+ for (int idx = 0; idx < baseCorners.Length; idx++)
+ {
+ double left = baseCorners[idx] - 500;
+ double right = baseCorners[idx] + 500;
+ int leftIndex = lowerBound(noteTimes, left);
+ int rightIndex = lowerBound(noteTimes, right);
+ cStep[idx] = Math.Max(rightIndex - leftIndex, 0);
+ int activeCount = 0;
+
+ for (int col = 0; col < keyCount; col++)
+ {
+ if (keyUsage[col][idx])
+ activeCount++;
+ }
+
+ ksStep[idx] = Math.Max(activeCount, 1);
+ }
+
+ return (cStep, ksStep);
+ }
+
+ private static double[] computeGaps(double[] corners)
+ {
+ if (corners.Length == 0) return Array.Empty();
+
+ double[] gaps = new double[corners.Length];
+
+ if (corners.Length == 1)
+ {
+ gaps[0] = 0;
+ return gaps;
+ }
+
+ gaps[0] = (corners[1] - corners[0]) / 2.0;
+ gaps[^1] = (corners[^1] - corners[^2]) / 2.0;
+ for (int i = 1; i < corners.Length - 1; i++) gaps[i] = (corners[i + 1] - corners[i - 1]) / 2.0;
+ return gaps;
+ }
+
+ private static NoteStruct? findNextColumnNote(NoteStruct note, List[] notesByColumn)
+ {
+ var columnNotes = notesByColumn[note.Column];
+ int index = columnNotes.IndexOf(note);
+ if (index >= 0 && index + 1 < columnNotes.Count) return columnNotes[index + 1];
+
+ return null;
+ }
+
+ private static double[] interpValues(double[] newX, double[] oldX, double[] oldVals)
+ {
+ double[] result = new double[newX.Length];
+
+ for (int i = 0; i < newX.Length; i++)
+ {
+ double x = newX[i];
+
+ if (x <= oldX[0])
+ {
+ result[i] = oldVals[0];
+ continue;
+ }
+
+ if (x >= oldX[^1])
+ {
+ result[i] = oldVals[^1];
+ continue;
+ }
+
+ int idx = lowerBound(oldX, x);
+
+ if (idx < oldX.Length && nearlyEquals(oldX[idx], x))
+ {
+ result[i] = oldVals[idx];
+ continue;
+ }
+
+ int prev = Math.Max(idx - 1, 0);
+ double x0 = oldX[prev];
+ double x1 = oldX[idx];
+ double y0 = oldVals[prev];
+ double y1 = oldVals[idx];
+ double deltaY = y1 - y0;
+ double deltaX = x - x0;
+ double numerator = deltaY * deltaX;
+ double fraction = numerator / (x1 - x0);
+ result[i] = y0 + fraction;
+ }
+
+ return result;
+ }
+
+ private static double[] stepInterp(double[] newX, double[] oldX, double[] oldVals)
+ {
+ double[] result = new double[newX.Length];
+
+ for (int i = 0; i < newX.Length; i++)
+ {
+ int idx = upperBound(oldX, newX[i]) - 1;
+ if (idx < 0) idx = 0;
+ result[i] = oldVals[Math.Min(idx, oldVals.Length - 1)];
+ }
+
+ return result;
+ }
+
+ private static double[] SmoothOnCorners(double[] positions, double[] values, double window, double scale, SmoothMode mode)
+ {
+ if (positions.Length == 0) return Array.Empty();
+
+ double[] cumulative = buildCumulative(positions, values);
+ double[] output = new double[positions.Length];
+
+ for (int i = 0; i < positions.Length; i++)
+ {
+ double s = positions[i];
+ double a = Math.Max(s - window, positions[0]);
+ double b = Math.Min(s + window, positions[^1]);
+
+ if (b <= a)
+ {
+ output[i] = 0;
+ continue;
+ }
+
+ double integral = queryIntegral(positions, cumulative, values, b) - queryIntegral(positions, cumulative, values, a);
+ if (mode == SmoothMode.Average) output[i] = integral / Math.Max(b - a, 1e-9);
+ else output[i] = integral * scale;
+ }
+
+ return output;
+ }
+
+ private static double[] buildCumulative(double[] positions, double[] values)
+ {
+ double[] cumulative = new double[positions.Length];
+
+ for (int i = 1; i < positions.Length; i++)
+ {
+ double width = positions[i] - positions[i - 1];
+ double increment = values[i - 1] * width;
+ cumulative[i] = cumulative[i - 1] + increment;
+ }
+
+ return cumulative;
+ }
+
+ private static double queryIntegral(double[] positions, double[] cumulative, double[] values, double point)
+ {
+ if (point <= positions[0]) return 0;
+
+ if (point >= positions[^1]) return cumulative[^1];
+
+ int idx = lowerBound(positions, point);
+ if (idx < positions.Length && nearlyEquals(positions[idx], point)) return cumulative[idx];
+
+ int prev = Math.Max(idx - 1, 0);
+ double delta = point - positions[prev];
+ double contribution = values[prev] * delta;
+ return cumulative[prev] + contribution;
+ }
+
+ private static double streamBooster(double delta)
+ {
+ double inv = 7.5 / Math.Max(delta, 1e-6);
+ if (inv <= 160 || inv >= 360) return 1;
+
+ double shifted = inv - 160;
+ double distance = inv - 360;
+ double adjustment = 1.7e-7 * shifted * Math.Pow(distance, 2);
+ return 1 + adjustment;
+ }
+
+ private static bool contains(int[] array, int target)
+ {
+ if (target < 0) return false;
+
+ for (int i = 0; i < array.Length; i++)
+ {
+ if (array[i] == target)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static int lowerBound(double[] array, double value)
+ {
+ int left = 0;
+ int right = array.Length;
+
+ while (left < right)
+ {
+ int span = right - left;
+ int mid = left + (span >> 1);
+ if (array[mid] < value) left = mid + 1;
+ else right = mid;
+ }
+
+ return left;
+ }
+
+ private static int lowerBound(double[] array, int value) => lowerBound(array, (double)value);
+
+ private static int lowerBound(int[] array, double value)
+ {
+ int left = 0;
+ int right = array.Length;
+
+ while (left < right)
+ {
+ int span = right - left;
+ int mid = left + (span >> 1);
+ if (array[mid] < value) left = mid + 1;
+ else right = mid;
+ }
+
+ return left;
+ }
+
+ private static int upperBound(int[] array, int value)
+ {
+ int left = 0;
+ int right = array.Length;
+
+ while (left < right)
+ {
+ int span = right - left;
+ int mid = left + (span >> 1);
+ if (array[mid] <= value) left = mid + 1;
+ else right = mid;
+ }
+
+ return left;
+ }
+
+ private static int upperBound(double[] array, double value)
+ {
+ int left = 0;
+ int right = array.Length;
+
+ while (left < right)
+ {
+ int span = right - left;
+ int mid = left + (span >> 1);
+ if (array[mid] <= value) left = mid + 1;
+ else right = mid;
+ }
+
+ return left;
+ }
+
+ private static int bisectLeft(double[] array, double value)
+ {
+ int left = 0;
+ int right = array.Length;
+
+ while (left < right)
+ {
+ int span = right - left;
+ int mid = left + (span >> 1);
+ if (array[mid] < value) left = mid + 1;
+ else right = mid;
+ }
+
+ return left;
+ }
+
+ private static double safePow(double value, double exponent)
+ {
+ if (value <= 0) return 0;
+
+ return Math.Pow(value, exponent);
+ }
+
+ private static double rescaleHigh(double sr)
+ {
+ double excess = sr - 9;
+ double normalized = excess / 1.2;
+ double softened = 9 + normalized;
+ return sr <= 9 ? sr : softened;
+ }
+
+ private static int clamp(int value, int min, int max) { return Math.Min(Math.Max(value, min), max); }
+
+ private static bool nearlyEquals(double a, double b, double epsilon = 1e-9) { return Math.Abs(a - b) <= epsilon; }
+
+ private static int compareNotes(NoteStruct a, NoteStruct b)
+ {
+ int headCompare = a.HeadTime.CompareTo(b.HeadTime);
+ return headCompare != 0 ? headCompare : a.Column.CompareTo(b.Column);
+ }
+
+ private static void addToMap(Dictionary map, int key, double value)
+ {
+ if (!map.TryAdd(key, value)) map[key] += value;
+ }
+
+ private enum SmoothMode { Sum, Average }
+
+ #endregion
+ }
+}
diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs
new file mode 100644
index 0000000000..33c461cc83
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs
@@ -0,0 +1,259 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.IO;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Tests.Visual;
+using osuTK;
+
+namespace osu.Game.Rulesets.Mania.Tests.Analysis
+{
+ public partial class TestSceneSRTune : OsuTestScene
+ {
+ private FillFlowContainer listContainer = null!;
+ private Dictionary last = new Dictionary();
+ private Dictionary current = new Dictionary();
+
+ // exposed tuning parameters (initialized from Tunables defaults)
+ private double ln_weight = SRCalculatorTunable.Tunables.FinalLNToNotesFactor; // 0.0 - 1.0
+ private double ln_len_cap = SRCalculatorTunable.Tunables.FinalLNLenCap; // ms, 100 - 2000
+ private double totalnotes_offset = SRCalculatorTunable.Tunables.TotalNotesOffset; // 0 - 500
+ private double pbar_ln_coeff = SRCalculatorTunable.Tunables.PBarLnMultiplier; // 0.0 - 0.02
+ private double jack_multiplier = SRCalculatorTunable.Tunables.JackPenaltyMultiplier; // 10 - 40
+ private double final_scale = SRCalculatorTunable.Tunables.FinalScale; // 0.9 - 1.05
+
+ private IBeatmap[] sampleBeatmaps = Array.Empty();
+
+ // storage for last/two-setting snapshots
+ private (double lnWeight, double lnLenCap, double offset, double pbarCoeff, double jackMult, double scale) lastSettings;
+
+ public TestSceneSRTune()
+ {
+ Child = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(6f),
+ Padding = new MarginPadding(10),
+ Children = new[]
+ {
+ // control row is exposed as test steps rather than placed into the scene hierarchy
+ listContainer = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(4f),
+ AutoSizeAxes = Axes.Y
+ }
+ }
+ };
+
+ loadSampleBeatmaps();
+
+ // register test steps for sliders and button controls (appear in the test steps UI)
+ AddSliderStep("LN Weight", 0, 1, SRCalculatorTunable.Tunables.FinalLNToNotesFactor, v => SRCalculatorTunable.Tunables.FinalLNToNotesFactor = v);
+ AddSliderStep("LN Len Cap", 100, 2000, SRCalculatorTunable.Tunables.FinalLNLenCap, v => SRCalculatorTunable.Tunables.FinalLNLenCap = v);
+ AddSliderStep("Offset", 0, 500, SRCalculatorTunable.Tunables.TotalNotesOffset, v => SRCalculatorTunable.Tunables.TotalNotesOffset = v);
+ AddSliderStep("pBar LN coeff", 0, 0.02, SRCalculatorTunable.Tunables.PBarLnMultiplier, v => SRCalculatorTunable.Tunables.PBarLnMultiplier = v);
+ AddSliderStep("Jack mult", 10, 40, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, v => SRCalculatorTunable.Tunables.JackPenaltyMultiplier = v);
+ AddSliderStep("Final scale", 0.9, 1.05, SRCalculatorTunable.Tunables.FinalScale, v => SRCalculatorTunable.Tunables.FinalScale = v);
+
+ // save current settings to "last" snapshot
+ AddStep("Save as last settings", () =>
+ {
+ lastSettings = (SRCalculatorTunable.Tunables.FinalLNToNotesFactor, SRCalculatorTunable.Tunables.FinalLNLenCap, SRCalculatorTunable.Tunables.TotalNotesOffset, SRCalculatorTunable.Tunables.PBarLnMultiplier, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, SRCalculatorTunable.Tunables.FinalScale);
+ // also snapshot last SR values from current
+ foreach (string k in current.Keys)
+ last[k] = current[k];
+ });
+
+ // update every 50ms
+ Scheduler.AddDelayed(updateAllSR, 50, true);
+ }
+
+ private IBeatmap[] createSyntheticSamples()
+ {
+ var list = new List();
+
+ for (int i = 0; i < 8; i++)
+ {
+ var bm = new ManiaBeatmap(new StageDefinition(4));
+ bm.BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { Title = $"Sample {i + 1}" } };
+
+ for (int t = 0; t < 2000; t += 250)
+ {
+ // alternate between simple notes and hold notes to exercise LN codepaths
+ if (i % 2 == 0)
+ {
+ var note = new Note { StartTime = t + i * 10, Column = i % 4 };
+ bm.HitObjects.Add(note);
+ }
+ else
+ {
+ var hold = new HoldNote { StartTime = t + i * 10, Column = i % 4 };
+ hold.Duration = 400 + i * 50;
+ bm.HitObjects.Add(hold);
+ }
+ }
+
+ list.Add(bm);
+ }
+
+ return list.ToArray();
+ }
+
+ private Drawable buildControlRow()
+ {
+ // Controls are exposed via test steps (AddSliderStep / AddStep) instead of being placed in the scene.
+ return new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y };
+ }
+
+ private void loadSampleBeatmaps()
+ {
+ // try load canonical test beatmap resource first
+ try
+ {
+ string resourcePath = @"Resources/Testing/Beatmaps/4869637.osu";
+
+ // prefer a set of deterministic half-hold beatmaps for side-by-side tuning
+ sampleBeatmaps = HalfHoldBeatmapSets.CreateTen(4, 32);
+ }
+ catch
+ {
+ sampleBeatmaps = createSyntheticSamples();
+ }
+
+ foreach (var bm in sampleBeatmaps)
+ {
+ string id = ((BeatmapInfo)bm.BeatmapInfo).Metadata.Title!;
+ last[id] = 0;
+ current[id] = 0;
+
+ var row = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Horizontal,
+ Spacing = new Vector2(10f),
+ Children = new Drawable[]
+ {
+ new OsuSpriteText { Text = id, Width = 180, Font = new FontUsage(size:20) },
+ new OsuSpriteText { Text = "Last: 0", Name = "last_" + id, Font = new FontUsage(size:20) },
+ new OsuSpriteText { Text = "Cur: 0", Name = "cur_" + id, Font = new FontUsage(size:20) },
+ // place holders for settings display under the SRs
+ new OsuSpriteText { Text = "Last settings: -", Name = "last_set_" + id, Font = new FontUsage(size:20) },
+ new OsuSpriteText { Text = "Cur settings: -", Name = "cur_set_" + id, Font = new FontUsage(size:20) }
+ }
+ };
+
+ listContainer.Add(row);
+ }
+ }
+
+ private void updateAllSR()
+ {
+ // read slider values
+ var sliders = this.ChildrenOfType>();
+
+ foreach (var s in sliders)
+ {
+ // decide by Width positions we set earlier
+ if (s.Width == 0.28f) ln_weight = s.Current.Value;
+ else if (s.Width == 0.2f) ln_len_cap = s.Current.Value;
+ else if (s.Width == 0.16f) totalnotes_offset = s.Current.Value;
+ else if (s.Width == 0.22f) pbar_ln_coeff = s.Current.Value;
+ else if (s.Width == 0.18f) jack_multiplier = s.Current.Value;
+ else if (s.Width == 0.12f) final_scale = s.Current.Value;
+ }
+
+ // propagate slider values into SRCalculatorTunable.Tunables
+ TunableSyncFromSliders();
+
+ for (int i = 0; i < sampleBeatmaps.Length; i++)
+ {
+ var bm = sampleBeatmaps[i];
+ string id = bm.BeatmapInfo.Metadata.Title;
+
+ double sr = SRCalculatorTunable.CalculateSR(bm);
+ current[id] = sr;
+
+ var row = listContainer.Children[i] as FillFlowContainer;
+ var curText = row?.Children[2] as SpriteText;
+ if (row?.Children[1] is SpriteText lastText) lastText.Text = $"Last: {last[id]:F2}";
+ if (curText != null) curText.Text = $"Cur: {sr:F2}";
+
+ // update settings display
+ var lastSetText = row?.Children[3] as SpriteText;
+ var curSetText = row?.Children[4] as SpriteText;
+ if (lastSetText != null)
+ lastSetText.Text = $"Last settings: ln_w={lastSettings.lnWeight:F3}, ln_cap={lastSettings.lnLenCap:F0}, off={lastSettings.offset:F0}, pbar={lastSettings.pbarCoeff:F4}, jack={lastSettings.jackMult:F1}, scale={lastSettings.scale:F3}";
+ if (curSetText != null)
+ curSetText.Text = $"Cur settings: ln_w={SRCalculatorTunable.Tunables.FinalLNToNotesFactor:F3}, ln_cap={SRCalculatorTunable.Tunables.FinalLNLenCap:F0}, off={SRCalculatorTunable.Tunables.TotalNotesOffset:F0}, pbar={SRCalculatorTunable.Tunables.PBarLnMultiplier:F4}, jack={SRCalculatorTunable.Tunables.JackPenaltyMultiplier:F1}, scale={SRCalculatorTunable.Tunables.FinalScale:F3}";
+ }
+ }
+
+ private void TunableSyncFromSliders()
+ {
+ var sliders = this.ChildrenOfType>();
+
+ foreach (var s in sliders)
+ {
+ if (s.Width == 0.28f) SRCalculatorTunable.Tunables.FinalLNToNotesFactor = s.Current.Value;
+ else if (s.Width == 0.2f) SRCalculatorTunable.Tunables.FinalLNLenCap = s.Current.Value;
+ else if (s.Width == 0.16f) SRCalculatorTunable.Tunables.TotalNotesOffset = s.Current.Value;
+ else if (s.Width == 0.22f) SRCalculatorTunable.Tunables.PBarLnMultiplier = s.Current.Value;
+ else if (s.Width == 0.18f) SRCalculatorTunable.Tunables.JackPenaltyMultiplier = s.Current.Value;
+ else if (s.Width == 0.12f) SRCalculatorTunable.Tunables.FinalScale = s.Current.Value;
+ }
+ }
+
+ // isolated compute method that uses exposed parameters
+ private double HotComputeSR(IBeatmap beatmap, double lnWeight, int lnLenCap, int offset, double pbarCoeff, double jackMult, double scale)
+ {
+ var mania = (ManiaBeatmap)beatmap;
+ int headCount = 0;
+ var lnLens = new List();
+
+ foreach (var ho in mania.HitObjects)
+ {
+ headCount++;
+ int tail = (int)ho.GetEndTime();
+ int len = Math.Max(0, Math.Min(tail - (int)ho.StartTime, lnLenCap));
+ if (len > 0) lnLens.Add(len);
+ }
+
+ double baseDifficulty = headCount * 0.1;
+
+ double lnContribution = 0;
+
+ foreach (int len in lnLens)
+ {
+ // combine ln weight and pbar coefficient
+ lnContribution += lnWeight * (len / 200.0) * 0.5 * (1.0 + pbarCoeff * 100.0);
+ }
+
+ double totalNotes = headCount + lnContribution;
+
+ // simplistic tail-based penalty: more LN tails -> slightly reduce sr, scaled by jackMult
+ double tailPenalty = 1.0 + lnLens.Count * 0.01 * (jackMult / 35.0);
+
+ double sr = baseDifficulty * (totalNotes / (totalNotes + offset)) / tailPenalty;
+ sr *= scale;
+ return Math.Max(0, sr);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs
new file mode 100644
index 0000000000..c27263ef21
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs
@@ -0,0 +1,142 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Mania.Mods.LAsMods;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Mania.Tests.Mods
+{
+ public partial class TestSceneManiaModSpaceBody : ModTestScene
+ {
+ protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
+
+ [Test]
+ public void TestDefaultSettings()
+ {
+ var mod = new ManiaModSpaceBody();
+
+ CreateModTest(new ModTestData
+ {
+ Mod = mod,
+ CreateBeatmap = () => new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+ HitObjects = new List
+ {
+ new Note { StartTime = 1000, Column = 0 },
+ new Note { StartTime = 2000, Column = 0 },
+ new Note { StartTime = 3000, Column = 0 },
+ new Note { StartTime = 1000, Column = 1 },
+ new Note { StartTime = 2500, Column = 1 },
+ new Note { StartTime = 1000, Column = 2 },
+ new Note { StartTime = 3500, Column = 2 }
+ }
+ },
+ PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
+ });
+ }
+
+ [Test]
+ public void TestWithSmallSpaceBeat()
+ {
+ var mod = new ManiaModSpaceBody
+ {
+ SpaceBeat = { Value = 2.0 }
+ };
+
+ CreateModTest(new ModTestData
+ {
+ Mod = mod,
+ CreateBeatmap = () => new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+ HitObjects = new List
+ {
+ new Note { StartTime = 1000, Column = 0 },
+ new Note { StartTime = 2000, Column = 0 },
+ new Note { StartTime = 3000, Column = 0 }
+ }
+ },
+ PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
+ });
+ }
+
+ [Test]
+ public void TestWithLargeSpaceBeat()
+ {
+ var mod = new ManiaModSpaceBody
+ {
+ SpaceBeat = { Value = 8.0 }
+ };
+
+ CreateModTest(new ModTestData
+ {
+ Mod = mod,
+ CreateBeatmap = () => new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+ HitObjects = new List
+ {
+ new Note { StartTime = 1000, Column = 0 },
+ new Note { StartTime = 2000, Column = 0 },
+ new Note { StartTime = 3000, Column = 0 }
+ }
+ },
+ PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
+ });
+ }
+
+ [Test]
+ public void TestWithShieldEnabled()
+ {
+ var mod = new ManiaModSpaceBody
+ {
+ Shield = { Value = true }
+ };
+
+ CreateModTest(new ModTestData
+ {
+ Mod = mod,
+ CreateBeatmap = () => new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+ HitObjects = new List
+ {
+ new Note { StartTime = 1000, Column = 0 },
+ new Note { StartTime = 2000, Column = 0 },
+ new Note { StartTime = 3000, Column = 0 }
+ }
+ },
+ PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
+ });
+ }
+
+ [Test]
+ public void TestWithHoldNotes()
+ {
+ var mod = new ManiaModSpaceBody();
+
+ CreateModTest(new ModTestData
+ {
+ Mod = mod,
+ CreateBeatmap = () => new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+ HitObjects = new List
+ {
+ new Note { StartTime = 1000, Column = 0 },
+ new HoldNote { StartTime = 2000, Duration = 500, Column = 0 },
+ new Note { StartTime = 3000, Column = 0 }
+ }
+ },
+ PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs
new file mode 100644
index 0000000000..dd1e892102
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs
@@ -0,0 +1,442 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable disable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.LAsEzMania.Analysis;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.Scoring;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
+using osu.Game.Tests.Visual;
+using osuTK;
+
+namespace osu.Game.Rulesets.Mania.Tests
+{
+ [TestFixture]
+ public partial class TestSceneEzHitEventHeatmapGraph : OsuTestScene
+ {
+ private ScoreInfo testScore;
+ private IBeatmap testBeatmap;
+
+ private const int RANDOM_SEED = 1234; // Fixed seed for consistent random data
+ private const int COLUMNS = 7; // 7k
+ private const int NOTES_PER_COLUMN = 50; // 50 notes per column
+
+ [SetUp]
+ public void SetUp()
+ {
+ // Create a test beatmap: 7k with 50 notes per column (350 total)
+ testBeatmap = createTestBeatmap();
+ // Create a test score with specific hit event distribution
+ testScore = createTestScore(testBeatmap);
+ }
+
+ [Test]
+ public void TestEmptyScore()
+ {
+ EzManiaScoreGraph graph = null;
+
+ AddStep("Create graph with empty score", () =>
+ {
+ var emptyScore = new ScoreInfo
+ {
+ BeatmapInfo = testBeatmap.BeatmapInfo,
+ Ruleset = testBeatmap.BeatmapInfo.Ruleset,
+ HitEvents = new List(),
+ Accuracy = 1.0,
+ TotalScore = 0,
+ Mods = Array.Empty()
+ };
+
+ Child = graph = new EzManiaScoreGraph(emptyScore, testBeatmap)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(800, 400),
+ };
+ });
+
+ AddAssert("Graph created successfully", () => graph != null);
+ AddAssert("Graph is visible", () => graph.IsPresent);
+ }
+
+ [Test]
+ public void TestPerfectScore()
+ {
+ EzManiaScoreGraph graph = null;
+
+ AddStep("Create graph with perfect score", () =>
+ {
+ var perfectScore = createPerfectScore(testBeatmap);
+
+ Child = graph = new EzManiaScoreGraph(perfectScore, testBeatmap)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(800, 400),
+ };
+ });
+
+ AddAssert("Graph created successfully", () => graph != null);
+ AddAssert("Graph is visible", () => graph.IsPresent);
+ AddAssert("Graph has hit events", () => graph != null && graph.DrawWidth > 0);
+ }
+
+ [Test]
+ public void TestMixedResults()
+ {
+ EzManiaScoreGraph graph = null;
+
+ AddStep("Create graph with test score", () =>
+ {
+ Child = graph = new EzManiaScoreGraph(testScore, testBeatmap)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(800, 400),
+ };
+ });
+
+ AddAssert("Graph created successfully", () => graph != null);
+ AddAssert("Graph is visible", () => graph.IsPresent);
+ AddAssert("Graph height is reasonable", () => graph != null && graph.DrawHeight > 0);
+ }
+
+ ///
+ /// Creates a 7k beatmap with 50 notes per column using fixed seed randomization.
+ /// Total: 7 columns * 50 notes = 350 notes
+ ///
+ private IBeatmap createTestBeatmap()
+ {
+ var beatmap = new ManiaBeatmap(new StageDefinition(COLUMNS))
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ Ruleset = new ManiaRuleset().RulesetInfo,
+ Difficulty = new BeatmapDifficulty
+ {
+ DrainRate = 8,
+ OverallDifficulty = 8,
+ ApproachRate = 8,
+ CircleSize = 4,
+ }
+ },
+ ControlPointInfo = new ControlPointInfo()
+ };
+
+ var random = new Random(RANDOM_SEED);
+
+ // Create 50 notes per column
+ for (int column = 0; column < COLUMNS; column++)
+ {
+ for (int i = 0; i < NOTES_PER_COLUMN; i++)
+ {
+ // Use fixed seed random for note timing within each column
+ double timeOffset = random.NextDouble() * 500; // Random offset between 0-500ms for each note
+
+ beatmap.HitObjects.Add(new Note
+ {
+ StartTime = column * 500 + i * 200 + timeOffset,
+ Column = column
+ });
+ }
+ }
+
+ return beatmap;
+ }
+
+ ///
+ /// Creates a test score with segmented symmetric normal distribution of timing offsets.
+ /// - [-40, 40]ms: 200 hits centered at 0ms (σ=20ms)
+ /// - [40, 100] and [-100, -40]ms: 50 hits centered at ±40ms (σ=30ms)
+ /// - [100, 150] and [-150, -100]ms: 20 hits centered at ±100ms (σ=25ms)
+ /// - [150, 200] and [-200, -150]ms: 10 hits centered at ±150ms (σ=25ms)
+ ///
+ private ScoreInfo createTestScore(IBeatmap beatmap)
+ {
+ var hitEvents = new List();
+ var random = new Random(RANDOM_SEED);
+
+ // Initialize hit windows based on beatmap difficulty
+ var hitWindows = new ManiaHitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
+ // Collect all offsets with their results
+ var allOffsetsWithResults = new List<(double offset, HitResult result)>();
+
+ // Segment 1: [-40, 40]ms with 200 hits
+ // Normal distribution centered at 0ms with σ=20ms (symmetric around 0)
+ for (int i = 0; i < 200; i++)
+ {
+ double offset = GenerateNormalOffset(random, 0, 20);
+ // Clamp to [-40, 40] range
+ offset = Math.Max(-40, Math.Min(40, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ // Segment 2: [40, 100]ms and [-100, -40]ms with 50 hits
+ // Normal distribution centered at ±40ms with σ=30ms
+ for (int i = 0; i < 25; i++)
+ {
+ // Positive side [40, 100]
+ double offset = GenerateNormalOffset(random, 40, 30);
+ offset = Math.Max(40, Math.Min(100, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ for (int i = 0; i < 25; i++)
+ {
+ // Negative side [-100, -40]
+ double offset = GenerateNormalOffset(random, -40, 30);
+ offset = Math.Max(-100, Math.Min(-40, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ // Segment 3: [100, 150]ms and [-150, -100]ms with 20 hits
+ // Normal distribution centered at ±100ms with σ=25ms
+ for (int i = 0; i < 10; i++)
+ {
+ // Positive side [100, 150]
+ double offset = GenerateNormalOffset(random, 100, 25);
+ offset = Math.Max(100, Math.Min(150, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ for (int i = 0; i < 10; i++)
+ {
+ // Negative side [-150, -100]
+ double offset = GenerateNormalOffset(random, -100, 25);
+ offset = Math.Max(-150, Math.Min(-100, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ // Segment 4: [150, 200]ms and [-200, -150]ms with 10 hits
+ // Normal distribution centered at ±150ms with σ=25ms
+ for (int i = 0; i < 5; i++)
+ {
+ // Positive side [150, 200]
+ double offset = GenerateNormalOffset(random, 150, 25);
+ offset = Math.Max(150, Math.Min(200, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ for (int i = 0; i < 5; i++)
+ {
+ // Negative side [-200, -150]
+ double offset = GenerateNormalOffset(random, -150, 25);
+ offset = Math.Max(-200, Math.Min(-150, offset));
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ // Now we have 280 hit events, need 70 more to reach 350
+ // Fill remaining with random distribution across all segments
+ while (allOffsetsWithResults.Count < beatmap.HitObjects.Count)
+ {
+ int segment = random.Next(4);
+ double offset = 0;
+ bool isNegative = random.Next(2) == 0; // Randomly choose positive or negative side
+
+ switch (segment)
+ {
+ case 0: // [-40, 40]ms
+ offset = GenerateNormalOffset(random, 0, 20);
+ offset = Math.Max(-40, Math.Min(40, offset));
+ break;
+
+ case 1: // [±40, ±100]ms
+ offset = GenerateNormalOffset(random, isNegative ? -40 : 40, 30);
+ if (isNegative)
+ offset = Math.Max(-100, Math.Min(-40, offset));
+ else
+ offset = Math.Max(40, Math.Min(100, offset));
+ break;
+
+ case 2: // [±100, ±150]ms
+ offset = GenerateNormalOffset(random, isNegative ? -100 : 100, 25);
+ if (isNegative)
+ offset = Math.Max(-150, Math.Min(-100, offset));
+ else
+ offset = Math.Max(100, Math.Min(150, offset));
+ break;
+
+ case 3: // [±150, ±200]ms
+ offset = GenerateNormalOffset(random, isNegative ? -150 : 150, 25);
+ if (isNegative)
+ offset = Math.Max(-200, Math.Min(-150, offset));
+ else
+ offset = Math.Max(150, Math.Min(200, offset));
+ break;
+ }
+
+ HitResult result = hitWindows.ResultFor(offset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ allOffsetsWithResults.Add((offset, result));
+ }
+
+ // Trim to exact count
+ allOffsetsWithResults = allOffsetsWithResults.Take(beatmap.HitObjects.Count).ToList();
+
+ // Shuffle to randomize distribution while maintaining segments
+ allOffsetsWithResults = allOffsetsWithResults.OrderBy(_ => random.Next()).ToList();
+
+ // Create HitEvents
+ for (int i = 0; i < allOffsetsWithResults.Count && i < beatmap.HitObjects.Count; i++)
+ {
+ double timeOffset = allOffsetsWithResults[i].offset;
+ HitResult result = allOffsetsWithResults[i].result;
+
+ hitEvents.Add(new HitEvent(timeOffset, null, result, beatmap.HitObjects[i], null, null));
+ }
+
+ double accuracy = CalculateAccuracy(hitEvents);
+
+ return new ScoreInfo
+ {
+ BeatmapInfo = beatmap.BeatmapInfo,
+ Ruleset = beatmap.BeatmapInfo.Ruleset,
+ HitEvents = hitEvents,
+ Accuracy = accuracy,
+ TotalScore = 424000,
+ MaxCombo = hitEvents.Count,
+ Mods = Array.Empty()
+ };
+ }
+
+ ///
+ /// Generate a random value from normal distribution using Box-Muller transform.
+ ///
+ private double GenerateNormalOffset(Random random, double mean, double stdDev)
+ {
+ double u1 = random.NextDouble();
+ double u2 = random.NextDouble();
+ double z0 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
+ return mean + z0 * stdDev;
+ }
+
+ ///
+ /// Calculate accuracy based on hit event results.
+ ///
+ private double CalculateAccuracy(List hitEvents)
+ {
+ double totalPoints = 0;
+ double maxPoints = 0;
+
+ foreach (var hitEvent in hitEvents)
+ {
+ maxPoints += 305; // Maximum points per note in Mania
+
+ switch (hitEvent.Result)
+ {
+ case HitResult.Perfect:
+ totalPoints += 305;
+ break;
+
+ case HitResult.Great:
+ totalPoints += 300;
+ break;
+
+ case HitResult.Good:
+ totalPoints += 200;
+ break;
+
+ case HitResult.Ok:
+ totalPoints += 100;
+ break;
+
+ case HitResult.Meh:
+ totalPoints += 50;
+ break;
+
+ default:
+ totalPoints += 0;
+ break;
+ }
+ }
+
+ return maxPoints > 0 ? totalPoints / maxPoints : 0;
+ }
+
+ private ScoreInfo createPerfectScore(IBeatmap beatmap)
+ {
+ var hitEvents = new List();
+
+ // Initialize hit windows based on beatmap difficulty
+ var hitWindows = new ManiaHitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
+ foreach (var hitObject in beatmap.HitObjects)
+ {
+ // Perfect timing offset (0ms)
+ double timeOffset = 0;
+
+ // Calculate the correct hit result based on hit windows at perfect timing
+ HitResult result = hitWindows.ResultFor(timeOffset);
+ if (result == HitResult.None)
+ result = HitResult.Miss;
+
+ hitEvents.Add(new HitEvent(timeOffset, null, result, hitObject, null, null));
+ }
+
+ return new ScoreInfo
+ {
+ BeatmapInfo = beatmap.BeatmapInfo,
+ Ruleset = beatmap.BeatmapInfo.Ruleset,
+ HitEvents = hitEvents,
+ Accuracy = 1.0,
+ TotalScore = 1000000,
+ MaxCombo = hitEvents.Count,
+ Mods = Array.Empty()
+ };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
index c55465762b..1aaf6e2377 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
@@ -8,10 +8,13 @@ using System.Collections.Generic;
using System.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
+using osu.Game.LAsEzExtensions.Background;
+using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
+using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
@@ -25,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
///
/// Maximum number of previous notes to consider for density calculation.
///
- private const int max_notes_for_density = 7;
+ private const int max_notes_for_density = 24;
///
/// The total number of columns.
@@ -47,6 +50,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
///
public readonly bool IsForCurrentRuleset;
+ ///
+ /// The current hit mode for mania judgement system.
+ ///
+ public static EzMUGHitMode CurrentHitMode { get; set; }
+
// Internal for testing purposes
internal readonly LegacyRandom Random;
@@ -60,6 +68,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
private ManiaBeatmapConverter(IBeatmap? beatmap, LegacyBeatmapConversionDifficultyInfo difficulty, Ruleset ruleset)
: base(beatmap!, ruleset)
{
+ CurrentHitMode = GlobalConfigStore.EzConfig?.Get(Ez2Setting.HitMode) ?? EzMUGHitMode.Lazer;
IsForCurrentRuleset = difficulty.SourceRuleset.Equals(ruleset.RulesetInfo);
Random = new LegacyRandom((int)MathF.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)MathF.Round(difficulty.ApproachRate));
TargetColumns = getColumnCount(difficulty);
@@ -132,7 +141,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
{
case ManiaHitObject maniaObj:
{
- yield return maniaObj;
+ if (maniaObj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer)
+ {
+ yield return CurrentHitMode switch
+ {
+ EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold),
+ EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold),
+ EzMUGHitMode.O2Jam => new O2HoldNote(hold),
+ EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold),
+ _ => hold
+ };
+ }
+ else
+ {
+ yield return maniaObj;
+ }
yield break;
}
@@ -227,7 +250,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
lastPattern = newPattern;
foreach (var obj in newPattern.HitObjects)
- yield return obj;
+ if (obj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer)
+ {
+ yield return CurrentHitMode switch
+ {
+ EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold),
+ EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold),
+ EzMUGHitMode.O2Jam => new O2HoldNote(hold),
+ EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold),
+ _ => hold
+ };
+ }
+ else
+ {
+ yield return obj;
+ }
}
}
diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
index d58347076d..42ed97245e 100644
--- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
+++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
@@ -3,8 +3,10 @@
using osu.Framework.Configuration.Tracking;
using osu.Game.Configuration;
+using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Localisation;
using osu.Game.Rulesets.Configuration;
+using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Configuration
@@ -17,11 +19,17 @@ namespace osu.Game.Rulesets.Mania.Configuration
Migrate();
}
+ private const double current_scroll_speed_precision = 1.0;
+
protected override void InitialiseDefaults()
{
base.InitialiseDefaults();
- SetDefault(ManiaRulesetSetting.ScrollSpeed, 8.0, 1.0, 40.0, 0.1);
+ SetDefault(ManiaRulesetSetting.ScrollBaseSpeed, 500, 100, 1000, 1.0);
+ SetDefault(ManiaRulesetSetting.ScrollTimePerSpeed, 5, 1.0, 40, 1.0);
+ SetDefault(ManiaRulesetSetting.ScrollStyle, EzManiaScrollingStyle.ScrollTimeStyleFixed);
+
+ SetDefault(ManiaRulesetSetting.ScrollSpeed, 200, 1.0, 401.0, current_scroll_speed_precision);
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait);
@@ -47,14 +55,24 @@ namespace osu.Game.Rulesets.Mania.Configuration
speed => new SettingDescription(
rawValue: speed,
name: RulesetSettingsStrings.ScrollSpeed,
- value: RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(speed), speed)
+ value: RulesetSettingsStrings.ScrollSpeedTooltip(
+ (int)DrawableManiaRuleset.ComputeScrollTime(speed, Get(ManiaRulesetSetting.ScrollBaseSpeed), Get(ManiaRulesetSetting.ScrollTimePerSpeed)),
+ speed
+ )
)
- )
+ ),
};
}
+ // TODO: 未来应考虑完全迁移到Ez2Setting中
public enum ManiaRulesetSetting
{
+ ScrollStyle,
+ ScrollTime,
+ ScrollBaseSpeed,
+ ScrollTimePerSpeed,
+
+ //官方设置
ScrollSpeed,
ScrollDirection,
TimingBasedNoteColouring,
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
index bcf16e6808..ea6d64af41 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
@@ -6,14 +6,18 @@ using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
+using osu.Game.LAsEzExtensions.Background;
+using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Skills;
+using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
using osu.Game.Rulesets.Mania.MathUtils;
using osu.Game.Rulesets.Mania.Mods;
+using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
@@ -41,12 +45,18 @@ namespace osu.Game.Rulesets.Mania.Difficulty
if (beatmap.HitObjects.Count == 0)
return new ManiaDifficultyAttributes { Mods = mods };
- HitWindows hitWindows = new ManiaHitWindows();
+ var hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+ if (beatmap.BeatmapInfo.BPM > 0) hitWindows.BPM = beatmap.BeatmapInfo.BPM;
+ double sr = skills[0].DifficultyValue() * difficulty_multiplier;
+
+ sr = AdditionalMethod(beatmap, mods, skills, clockRate, sr);
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
- StarRating = skills.OfType().Single().DifficultyValue() * difficulty_multiplier,
+ StarRating = sr > 0
+ ? sr
+ : skills.OfType().Single().DifficultyValue() * difficulty_multiplier,
Mods = mods,
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
@@ -54,6 +64,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty
return attributes;
}
+ public double AdditionalMethod(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate, double originalValue)
+ {
+ double sr = originalValue;
+
+ if (mods.Any(m => m is ModStarRatingRebirth))
+ {
+ var xxySRFilter = GlobalConfigStore.EzConfig?.GetBindable(Ez2Setting.XxySRFilter);
+
+ sr = xxySRFilter != null && xxySRFilter.Value
+ ? SRCalculator.CalculateSR(beatmap, clockRate)
+ : skills.OfType().Single().DifficultyValue() * difficulty_multiplier;
+ }
+
+ return sr;
+ }
+
private static int maxComboForObject(HitObject hitObject)
{
if (hitObject is HoldNote hold)
diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
index 181bc7341c..ea7e3dfe3e 100644
--- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
+++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
@@ -44,7 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override void Update()
{
- TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value;
+ // 使用ez2lazer特色调速系统
+ TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed), Config.Get(ManiaRulesetSetting.ScrollBaseSpeed), Config.Get(ManiaRulesetSetting.ScrollTimePerSpeed)) : TimelineTimeRange.Value;
base.Update();
}
}
diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs
new file mode 100644
index 0000000000..3939de10f8
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+
+namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis
+{
+ ///
+ /// 交叉矩阵提供者,用于SR计算中的列间 权重矩阵
+ ///
+ public static class CrossMatrixProvider
+ {
+ ///
+ /// 默认交叉矩阵数据,表示各键位两侧的权重分布
+ /// 索引0对应K=1,索引1对应K=2,以此类推
+ /// null表示不支持该键数
+ ///
+ // TODO: 未来考虑支持在游戏内配置这些矩阵动态调试对比
+ private static readonly double[][] default_cross_matrices =
+ [
+ [-1], // CS=0
+ [0.075, 0.075],
+ [0.125, 0.05, 0.125],
+ [0.125, 0.125, 0.125, 0.125],
+ [0.175, 0.25, 0.05, 0.25, 0.175],
+ [0.175, 0.25, 0.175, 0.175, 0.25, 0.175],
+ [0.225, 0.35, 0.25, 0.05, 0.25, 0.35, 0.225],
+ [0.225, 0.35, 0.25, 0.225, 0.225, 0.25, 0.35, 0.225],
+ [0.275, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.275],
+ [0.275, 0.45, 0.35, 0.25, 0.275, 0.275, 0.25, 0.35, 0.45, 0.275],
+ [0.325, 0.55, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.55, 0.325], // 10key
+ // Inferred matrices for K=11 to 18 based on user-specified patterns
+ [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=11 (odd, unsupported)
+ // 更高K的矩阵没有经过严格验证,仅提供占位
+ [0.8, 0.8, 0.8, 0.6, 0.4, 0.2, 0.05, 0.2, 0.4, 0.6, 0.8, 0.8, 0.8], // K=12 (even, sides 3 columns higher)
+ [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=13 (odd, unsupported)
+ [0.4, 0.4, 0.2, 0.2, 0.3, 0.3, 0.1, 0.1, 0.3, 0.3, 0.2, 0.2, 0.4, 0.4, 0.4], // K=14 (wave: low-low-high-high-low-low-high-high)
+ [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=15 (odd, unsupported)
+ [0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.2, 0.1, 0.1, 0.2, 0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.4], // K=16 (wave: low-low-high-high-low-low-high-high-low-low-high-high)
+ [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=17 (odd, unsupported)
+ [0.4, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.3, 0.1, 0.1, 0.3, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.4, 0.4] // K=18 (wave: low-low-high-low-high-low-high-low-low-high-low-high-low-high)
+ ];
+
+ ///
+ /// 自定义交叉矩阵,用于覆盖默认数据
+ ///
+ private static readonly Dictionary custom_matrices = new Dictionary();
+
+ ///
+ /// 设置自定义交叉矩阵
+ ///
+ /// 键数
+ /// 自定义矩阵数组,如果为null则清除自定义矩阵
+ public static void SetCustomMatrix(int k, double[]? matrix)
+ {
+ if (k < 1 || k > default_cross_matrices.Length)
+ throw new ArgumentOutOfRangeException(nameof(k), $"不支持的键数: {k},支持范围: 1-{default_cross_matrices.Length}");
+
+ if (matrix == null)
+ custom_matrices.Remove(k);
+ else
+ custom_matrices[k] = matrix;
+ }
+
+ ///
+ /// 获取指定键数(K)的交叉矩阵
+ /// K表示键数,从1开始索引
+ ///
+ /// 键数
+ /// 交叉矩阵数组,如果不支持返回null
+ public static double[]? GetMatrix(int k)
+ {
+ if (k < 1 || k > default_cross_matrices.Length)
+ return null;
+
+ return custom_matrices.TryGetValue(k, out double[]? customMatrix) ? customMatrix : default_cross_matrices[k];
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs
new file mode 100644
index 0000000000..d87555aab5
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs
@@ -0,0 +1,320 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.LAsEzExtensions.Analysis;
+using osu.Game.LAsEzExtensions.Configuration;
+using osu.Game.Rulesets.Mania.Scoring;
+using osu.Game.Rulesets.Mania.LAsEZMania.Helper;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis
+{
+ ///
+ /// Mania-specific implementation of score graph that extends BaseEzScoreGraph.
+ /// Provides LN (Long Note) aware scoring calculation for Classic mode.
+ ///
+ public partial class EzManiaScoreGraph : BaseEzScoreGraph
+ {
+ private readonly ManiaHitWindows maniaHitWindows = new ManiaHitWindows();
+
+ private readonly CustomHitWindowsHelper hitWindows1;
+ private readonly CustomHitWindowsHelper hitWindows2;
+ private Bindable hitModeBindable = null!;
+
+ [Resolved]
+ private Ez2ConfigManager ezConfig { get; set; } = null!;
+
+ public EzManiaScoreGraph(ScoreInfo score, IBeatmap beatmap)
+ : base(score, beatmap, new ManiaHitWindows())
+ {
+ maniaHitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
+ // Initialize helpers here (after base ctor has run and static OD/HP have been set).
+ hitWindows1 = new CustomHitWindowsHelper { OverallDifficulty = OD };
+ hitWindows2 = new CustomHitWindowsHelper { OverallDifficulty = OD };
+ }
+
+ protected override IReadOnlyList FilterHitEvents()
+ {
+ return Score.HitEvents.Where(e => maniaHitWindows.IsHitResultAllowed(e.Result)).ToList();
+ }
+
+ protected override double UpdateBoundary(HitResult result)
+ {
+ return maniaHitWindows.WindowFor(result);
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // Bind to the global hit mode setting so that switching hit modes updates our helpers and redraws.
+ hitModeBindable = ezConfig.GetBindable(Ez2Setting.HitMode);
+ hitModeBindable.BindValueChanged(v =>
+ {
+ hitWindows1.HitMode = v.NewValue;
+ hitWindows2.HitMode = v.NewValue;
+
+ // Ensure mania windows re-evaluate based on global config and difficulty.
+ maniaHitWindows.ResetRange();
+ maniaHitWindows.SetDifficulty(Beatmap.Difficulty.OverallDifficulty);
+
+ // Recalculate and redraw.
+ Refresh();
+ }, true);
+ }
+
+ protected override HitResult RecalculateV1Result(HitEvent hitEvent)
+ {
+ return hitWindows1.ResultFor(hitEvent.TimeOffset);
+ }
+
+ protected override HitResult RecalculateV2Result(HitEvent hitEvent)
+ {
+ return maniaHitWindows.ResultFor(hitEvent.TimeOffset);
+ }
+
+ protected override void UpdateText()
+ {
+ double scAcc = Score.Accuracy * 100;
+ long scScore = Score.TotalScore;
+
+ AddInternal(new GridContainer
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Position = Vector2.Zero,
+ RowDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ ColumnDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Acc org",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {scAcc:F1}%",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Acc v2",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Accuracy * 100:F1}%",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Acc v1",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V1Accuracy * 100:F1}%",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Scr org",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {scScore / 1000.0:F0}k",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Scr v2",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Score / 1000.0:F0}k",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Scr v1",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V1Score / 1000.0:F0}k",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "Pauses",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {Score.Pauses.Count}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "PERFECT",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Perfect, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Perfect, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "GREAT",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Great, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Great, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "GOOD",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Good, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Good, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "OK",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Ok, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Ok, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "MEH",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Meh, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Meh, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = "MISS",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ new OsuSpriteText
+ {
+ Text = $" : {V2Counts.GetValueOrDefault(HitResult.Miss, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Miss, 0)}",
+ Font = OsuFont.GetFont(size: 14),
+ Colour = Color4.White,
+ },
+ },
+ }
+ });
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph_clean.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph_clean.cs
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs
new file mode 100644
index 0000000000..cc227da501
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs
@@ -0,0 +1,244 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using osu.Game.Beatmaps;
+using osu.Game.LAsEzExtensions.Analysis;
+using osu.Game.Replays;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.Replays;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
+using osu.Game.Screens.Ranking.Statistics;
+using osu.Game.Utils;
+
+namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis
+{
+ ///
+ /// Generates s for mania scores by re-evaluating a score's replay input against a provided playable beatmap.
+ /// This is intended for results/statistics usage where are not persisted.
+ ///
+ public sealed class ManiaScoreHitEventGenerator : IHitEventGenerator
+ {
+ public static ManiaScoreHitEventGenerator Instance { get; } = new ManiaScoreHitEventGenerator();
+
+ ///
+ /// Instance implementation of generator.
+ ///
+ public List? Generate(Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (score.ScoreInfo.Ruleset.OnlineID != 3)
+ return null;
+
+ Replay replay = score.Replay;
+
+ // Legacy decoding should have produced mania frames.
+ if (replay?.Frames == null || replay.Frames.Count == 0)
+ return null;
+
+ if (replay.Frames.Any(f => f is not ManiaReplayFrame))
+ return null;
+
+ var frames = replay.Frames.Cast().OrderBy(f => f.Time).ToList();
+
+ // Build per-column input transitions.
+ var pressTimesByColumn = new List[32];
+ var releaseTimesByColumn = new List