diff --git a/.github/workflows/generate-keystore.yml b/.github/workflows/generate-keystore.yml new file mode 100644 index 000000000000..04d292a3d544 --- /dev/null +++ b/.github/workflows/generate-keystore.yml @@ -0,0 +1,111 @@ +name: Generate Android Signing Keystore + +on: + workflow_dispatch: + inputs: + key_password: + description: "Password for the keystore and key (min 6 characters)" + required: true + type: string + key_alias: + description: "Key alias name" + required: false + default: "osu-release" + type: string + validity_days: + description: "Certificate validity in days" + required: false + default: "10000" + type: string + +jobs: + generate: + name: Generate Keystore + runs-on: ubuntu-latest + permissions: {} + steps: + # Mask the password so it is never printed in logs. + - name: Mask password + run: echo "::add-mask::${{ inputs.key_password }}" + + - name: Validate inputs + run: | + if [ ${#KEY_PASSWORD} -lt 6 ]; then + echo "::error::Password must be at least 6 characters." + exit 1 + fi + env: + KEY_PASSWORD: ${{ inputs.key_password }} + + - name: Generate keystore + run: | + keytool -genkeypair -v \ + -keystore osu-release.keystore \ + -storepass "$KEY_PASSWORD" \ + -keypass "$KEY_PASSWORD" \ + -alias "$KEY_ALIAS" \ + -keyalg RSA -keysize 2048 \ + -validity "$VALIDITY_DAYS" \ + -dname "CN=osu! Android Release,O=osu,C=US" + + echo "Keystore generated successfully ✓" + keytool -list -v -keystore osu-release.keystore -storepass "$KEY_PASSWORD" | head -20 + env: + KEY_PASSWORD: ${{ inputs.key_password }} + KEY_ALIAS: ${{ inputs.key_alias }} + VALIDITY_DAYS: ${{ inputs.validity_days }} + + - name: Encode keystore as base64 + id: encode + run: | + B64=$(base64 -w 0 osu-release.keystore) + echo "$B64" > keystore-base64.txt + echo "encoded=true" >> "$GITHUB_OUTPUT" + + - name: Upload keystore artifact + uses: actions/upload-artifact@v7 + with: + name: osu-signing-keystore + path: osu-release.keystore + retention-days: 1 + + - name: Upload base64 artifact + uses: actions/upload-artifact@v7 + with: + name: osu-signing-keystore-base64 + path: keystore-base64.txt + retention-days: 1 + + - name: Output setup instructions + run: | + echo "" + echo "===================================================================" + echo " KEYSTORE GENERATED — SAVE THESE SECRETS NOW" + echo "===================================================================" + echo "" + echo " Go to: Settings → Secrets and variables → Actions → New repository secret" + echo "" + echo " Add these four secrets:" + echo "" + echo " 1. ANDROID_KEYSTORE_BASE64" + echo " → Value: contents of the 'keystore-base64.txt' file from the" + echo " 'osu-signing-keystore-base64' artifact (download it above)" + echo "" + echo " 2. ANDROID_SIGNING_KEY_ALIAS" + echo " → Value: ${{ inputs.key_alias }}" + echo "" + echo " 3. ANDROID_SIGNING_KEY_PASSWORD" + echo " → Value: the password you entered when triggering this workflow" + echo "" + echo " 4. ANDROID_SIGNING_STORE_PASSWORD" + echo " → Value: the password you entered when triggering this workflow" + echo "" + echo " IMPORTANT:" + echo " • Keep a backup of the keystore file! If you lose it, you will" + echo " never be able to update your installed APK — you would have to" + echo " uninstall and reinstall (losing all local data)." + echo " • After saving the secrets, DELETE this workflow run to remove" + echo " the keystore artifact (Settings → Actions → this run → Delete)." + echo "" + echo " After saving the secrets, run the 'Build Android APK' workflow." + echo "===================================================================" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fca32e31c34..a1a49d8ced41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,17 @@ on: - '*.*.*' - '!*-*' workflow_dispatch: + inputs: + version: + description: "Version number (e.g. 1.0.0). Leave empty for auto-increment." + required: false + default: "" + type: string + create_release: + description: "Create a GitHub Release with the APK?" + required: false + default: true + type: boolean jobs: build-android: @@ -66,37 +77,94 @@ jobs: echo "Native libraries built successfully:" find osu.Android/libs -name "*.so" -exec ls -lh {} \; - - name: Decode keystore + - name: Decode or generate keystore id: keystore run: | + KS_PATH="${{ github.workspace }}/osu.Android/osu.keystore" + if [ -n "$KEYSTORE_BASE64" ]; then - echo "$KEYSTORE_BASE64" | base64 --decode > "${{ github.workspace }}/osu.Android/osu.keystore" - echo "has_keystore=true" >> "$GITHUB_OUTPUT" + # ── User provided a persistent keystore secret ────────────── + echo "$KEYSTORE_BASE64" | base64 --decode > "$KS_PATH" + echo "has_keystore=true" >> "$GITHUB_OUTPUT" + echo "generated=false" >> "$GITHUB_OUTPUT" + echo "key_alias=$KEY_ALIAS_SECRET" >> "$GITHUB_OUTPUT" + echo "key_pass=$KEY_PASS_SECRET" >> "$GITHUB_OUTPUT" + echo "store_pass=$STORE_PASS_SECRET" >> "$GITHUB_OUTPUT" + echo "✅ Using saved keystore from repository secrets." else - echo "has_keystore=false" >> "$GITHUB_OUTPUT" + # ── No secret → auto-generate a keystore for this build ───── + # The APK will install fine on any device, but UPDATING from a + # previous build signed with a DIFFERENT key will fail. + # To avoid that, save the generated keystore as a secret + # (instructions are printed at the end of the build). + AUTO_PASS="osu-$(openssl rand -hex 12)" + echo "::add-mask::$AUTO_PASS" + + keytool -genkeypair \ + -keystore "$KS_PATH" \ + -storepass "$AUTO_PASS" \ + -keypass "$AUTO_PASS" \ + -alias osu-release \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=osu! Android,O=osu,C=US" 2>/dev/null + + echo "has_keystore=true" >> "$GITHUB_OUTPUT" + echo "generated=true" >> "$GITHUB_OUTPUT" + echo "key_alias=osu-release" >> "$GITHUB_OUTPUT" + echo "key_pass=$AUTO_PASS" >> "$GITHUB_OUTPUT" + echo "store_pass=$AUTO_PASS" >> "$GITHUB_OUTPUT" + + # Export base64 and password into a single instructions file + # so the user only needs to download one artifact. + { + echo "=== osu! Android Signing Keystore ===" + echo "" + echo "ANDROID_KEYSTORE_BASE64 value (copy everything on the next line):" + base64 -w 0 "$KS_PATH" + echo "" + echo "" + echo "ANDROID_SIGNING_KEY_ALIAS value:" + echo "osu-release" + echo "" + echo "ANDROID_SIGNING_KEY_PASSWORD value:" + echo "$AUTO_PASS" + echo "" + echo "ANDROID_SIGNING_STORE_PASSWORD value:" + echo "$AUTO_PASS" + } > "${{ github.workspace }}/SAVE-THESE-SECRETS.txt" + + echo "::warning::No signing keystore secret found — auto-generated one for this build." + echo "::warning::See the end of this job for instructions to save it for future builds." fi env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEY_ALIAS_SECRET: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + KEY_PASS_SECRET: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + STORE_PASS_SECRET: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} - - name: Set version + - name: Determine version id: version run: | + # Priority: manual input > tag name > auto-increment from run number + INPUT_VERSION="${{ inputs.version }}" REF="${{ github.ref_name }}" - if [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "version=$REF" >> "$GITHUB_OUTPUT" + + if [[ -n "$INPUT_VERSION" && "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="$INPUT_VERSION" + echo "📦 Using manually specified version: $VERSION" + elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="$REF" + echo "📦 Using version from tag: $VERSION" else - echo "version=0.0.0" >> "$GITHUB_OUTPUT" + # Auto-generate: YYYY.MMDD.run_number + VERSION="$(date -u +%Y).$(date -u +%-m%d).${{ github.run_number }}" + echo "📦 Auto-generated version: $VERSION" fi - # Always build Release for full optimization (trimming, AOT, compression). - # When a keystore is available we sign with it; otherwise the SDK produces - # a debug-signed Release APK that can be sideloaded for testing. - - name: Build Android APK (signed) - if: steps.keystore.outputs.has_keystore == 'true' - env: - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} - ANDROID_KEY_PASS: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} - ANDROID_STORE_PASS: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build Android APK run: > dotnet publish -c Release osu.Android/osu.Android.csproj @@ -106,111 +174,114 @@ jobs: -p:ApplicationVersion="${{ github.run_number }}" -p:AndroidKeyStore=true -p:AndroidSigningKeyStore="${{ github.workspace }}/osu.Android/osu.keystore" - -p:AndroidSigningKeyAlias="$ANDROID_KEY_ALIAS" - -p:AndroidSigningKeyPass="$ANDROID_KEY_PASS" - -p:AndroidSigningStorePass="$ANDROID_STORE_PASS" - -p:CustomBeforeMicrosoftCommonTargets="${{ github.workspace }}/build/SuppressSubmoduleWarnings.targets" - - - name: Build Android APK (unsigned Release) - if: steps.keystore.outputs.has_keystore != 'true' - run: > - dotnet publish -c Release - osu.Android/osu.Android.csproj - -f net10.0-android - -p:Version="${{ steps.version.outputs.version }}" - -p:ApplicationDisplayVersion="${{ steps.version.outputs.version }}" - -p:ApplicationVersion="${{ github.run_number }}" + -p:AndroidSigningKeyAlias="${{ steps.keystore.outputs.key_alias }}" + -p:AndroidSigningKeyPass="${{ steps.keystore.outputs.key_pass }}" + -p:AndroidSigningStorePass="${{ steps.keystore.outputs.store_pass }}" -p:CustomBeforeMicrosoftCommonTargets="${{ github.workspace }}/build/SuppressSubmoduleWarnings.targets" - - name: Find APK + - name: Find and rename APK id: find_apk run: | - # Both paths build Release. Signed builds produce *-Signed.apk; unsigned - # builds produce the base APK name. Search publish dir first, then fallback. - if [ "${{ steps.keystore.outputs.has_keystore }}" == "true" ]; then - APK=$(find "osu.Android/bin/Release/net10.0-android/publish" -maxdepth 1 -name "*-Signed.apk" 2>/dev/null | head -1) - if [ -z "$APK" ]; then - APK=$(find "osu.Android/bin/Release" -name "*-Signed.apk" | head -1) - fi - else - APK=$(find "osu.Android/bin/Release/net10.0-android/publish" -maxdepth 1 -name "*.apk" 2>/dev/null | head -1) - if [ -z "$APK" ]; then - APK=$(find "osu.Android/bin/Release" -name "*.apk" | head -1) - fi + APK=$(find "osu.Android/bin/Release/net10.0-android/publish" -maxdepth 1 -name "*-Signed.apk" 2>/dev/null | head -1) + if [ -z "$APK" ]; then + APK=$(find "osu.Android/bin/Release" -name "*-Signed.apk" | head -1) fi if [ -z "$APK" ]; then - echo "::error::Failed to locate APK. Listing bin directory:" + echo "::error::Failed to locate signed APK. Listing bin directory:" find osu.Android/bin -name "*.apk" -o -name "*.aab" 2>/dev/null || true exit 1 fi - APK_SIZE=$(stat -c %s "$APK" 2>/dev/null || stat -f %z "$APK" 2>/dev/null || wc -c < "$APK") + # Rename to a clean versioned filename for the release + VERSION="${{ steps.version.outputs.version }}" + FINAL_APK="$(dirname "$APK")/osu-lazer-$VERSION.apk" + cp "$APK" "$FINAL_APK" + + APK_SIZE=$(stat -c %s "$FINAL_APK" 2>/dev/null || stat -f %z "$FINAL_APK" 2>/dev/null || wc -c < "$FINAL_APK") APK_SIZE_MB=$((APK_SIZE / 1048576)) - echo "Found APK: $APK ($APK_SIZE_MB MB)" - echo "apk_path=$APK" >> "$GITHUB_OUTPUT" + echo "Found APK: $FINAL_APK ($APK_SIZE_MB MB)" + echo "apk_path=$FINAL_APK" >> "$GITHUB_OUTPUT" - # .NET 10 Android SDK may skip debug-signing for Release publish builds. - # Verify the APK is signed; if not, sign it with apksigner using the debug - # keystore so the APK can be sideloaded without INSTALL_PARSE_FAILED_NO_CERTIFICATES. - - name: Verify and sign APK if needed + - name: Verify APK signature run: | APK="${{ steps.find_apk.outputs.apk_path }}" APKSIGNER="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/apksigner" - ZIPALIGN="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/zipalign" - if "$APKSIGNER" verify "$APK" 2>/dev/null; then - echo "APK is already signed ✓" + if "$APKSIGNER" verify --print-certs "$APK"; then + echo "APK signature verified ✓" else - echo "::warning::APK is not signed. Signing with debug keystore..." - - # Generate debug keystore if it doesn't exist - DEBUG_KS="$HOME/.android/debug.keystore" - if [ ! -f "$DEBUG_KS" ]; then - mkdir -p "$HOME/.android" - keytool -genkeypair -v \ - -keystore "$DEBUG_KS" \ - -storepass android \ - -keypass android \ - -alias androiddebugkey \ - -keyalg RSA -keysize 2048 -validity 10000 \ - -dname "CN=Android Debug,O=Android,C=US" - fi - - # Zipalign first (required before apksigner v2 signing) - ALIGNED_APK="${APK%.apk}-aligned.apk" - "$ZIPALIGN" -f -p 4 "$APK" "$ALIGNED_APK" - mv "$ALIGNED_APK" "$APK" - - # Sign with debug keystore (v1 + v2 + v3 schemes) - "$APKSIGNER" sign \ - --ks "$DEBUG_KS" \ - --ks-pass pass:android \ - --key-pass pass:android \ - --ks-key-alias androiddebugkey \ - "$APK" - - # Verify signature - if "$APKSIGNER" verify --print-certs "$APK"; then - echo "APK signed successfully ✓" - else - echo "::error::APK signing failed" - exit 1 - fi + echo "::error::APK signature verification failed. The APK may not install correctly." + exit 1 fi - name: Upload APK artifact uses: actions/upload-artifact@v7 with: - name: osu-android-${{ github.ref_name }} + name: osu-android-${{ steps.version.outputs.version }} path: ${{ steps.find_apk.outputs.apk_path }} if-no-files-found: error + # When the keystore was auto-generated, upload it so the user can save it + # as a repository secret for consistent signing across builds. + - name: Upload generated keystore + if: steps.keystore.outputs.generated == 'true' + uses: actions/upload-artifact@v7 + with: + name: osu-signing-keystore + path: | + ${{ github.workspace }}/osu.Android/osu.keystore + ${{ github.workspace }}/SAVE-THESE-SECRETS.txt + retention-days: 7 + + - name: Print keystore setup instructions + if: steps.keystore.outputs.generated == 'true' + run: | + echo "" + echo "==================================================================" + echo " ⚠️ YOUR APK WAS SIGNED WITH AN AUTO-GENERATED KEYSTORE" + echo "==================================================================" + echo "" + echo " ✅ The APK will install fine on any device." + echo "" + echo " ⚠️ BUT — if you build again without saving this keystore," + echo " Android will REFUSE to update (different signing certificate)." + echo "" + echo " To keep your APK updatable across builds:" + echo "" + echo " 1. Download the 'osu-signing-keystore' artifact from this run" + echo " 2. Open 'SAVE-THESE-SECRETS.txt' — it contains all 4 values" + echo " 3. Go to: Settings → Secrets and variables → Actions" + echo " 4. Create these 4 secrets with the values from the file:" + echo "" + echo " • ANDROID_KEYSTORE_BASE64" + echo " • ANDROID_SIGNING_KEY_ALIAS" + echo " • ANDROID_SIGNING_KEY_PASSWORD" + echo " • ANDROID_SIGNING_STORE_PASSWORD" + echo "" + echo " ⚡ After saving the secrets, all future builds will use the" + echo " same keystore automatically — no more setup needed." + echo "==================================================================" + + # Create a GitHub Release with the APK attached. + # Works for both tag pushes and manual workflow_dispatch runs. + # For manual runs: creates a tag automatically. - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/') + if: inputs.create_release != false uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.version.outputs.tag }} + name: "osu! Android ${{ steps.version.outputs.version }}" files: ${{ steps.find_apk.outputs.apk_path }} generate_release_notes: true + body: | + ## osu! Android v${{ steps.version.outputs.version }} + + ### Download + Grab the APK below and install it on your Android device. + + > **Minimum Android version:** 13 (API 33) + > **Architecture:** arm64-v8a + make_latest: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 1629e71857d6..17599fb7f43b 100644 --- a/README.md +++ b/README.md @@ -2,188 +2,346 @@ osu! logo

-# osu! +# osu! (Android-optimised fork) -[![Build status](https://github.com/ppy/osu/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/ppy/osu/actions/workflows/ci.yml) -[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest) -[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) -[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/osu-web/localized.svg)](https://crowdin.com/project/osu-web) +[![Build Android APK](https://github.com/winnerspiros/osu/actions/workflows/release.yml/badge.svg)](https://github.com/winnerspiros/osu/actions/workflows/release.yml) +[![CI](https://github.com/winnerspiros/osu/actions/workflows/ci.yml/badge.svg)](https://github.com/winnerspiros/osu/actions/workflows/ci.yml) +[![GitHub release](https://img.shields.io/github/release/winnerspiros/osu.svg)](https://github.com/winnerspiros/osu/releases/latest) -A free-to-win rhythm game. Rhythm is just a *click* away! +A fork of [ppy/osu](https://github.com/ppy/osu) (osu! lazer) with deep Android platform integration — low-latency audio, Samsung optimisations, Vulkan GPU probing, and production-ready APK builds. -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. +> **📱 Download the latest APK:** Go to [Releases](https://github.com/winnerspiros/osu/releases/latest) and download `osu-lazer-*.apk`. +> Requires **Android 13+** (arm64). -## 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. +## What's different from upstream ppy/osu? -A few resources are available as starting points to getting involved and understanding the project: +This fork adds **~5,000 lines of custom code** (managed C# + native C++) to turn osu! lazer into a performance-tuned Android rhythm game. The upstream ppy/osu has minimal Android support — this fork fills in everything needed for a production-quality mobile experience. -- 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). +### 🔊 Low-latency audio (Google Oboe) -## Running osu! +The single most important change for a rhythm game. Upstream uses the default Android audio path, which adds 50–200 ms of latency — unacceptable for gameplay. -If you are just looking to give the game a whirl, you can grab the latest release for your platform: +| Feature | Upstream | This fork | +|---------|----------|-----------| +| Audio backend | Default Android (high latency) | [Google Oboe](https://github.com/google/oboe) via native C++ bridge | +| Audio API | OpenSL ES | AAudio with MMAP (shared memory) when available, OpenSL ES fallback | +| Measured latency | Not measured | Real-time measurement via `stream->calculateLatencyMillis()` | +| Audio offset | Manual user guess | Auto-suggested from measured hardware latency | +| Buffer tuning | Fixed | Dynamic via Oboe `LatencyTuner` (shrinks to 1× burst when stable) | +| Callback stability | N/A | `StabilizedCallback` wrapper smooths execution jitter | +| ADPF hints | No | `setPerformanceHintEnabled(true)` tells Android to prioritise the audio thread | -### Latest release: +The audio bridge (`osu.Android/Native/oboe_bridge.cpp`) runs as an unmanaged C++ callback at real-time priority. BASS audio mixers are redirected into this callback via `OboeAudioRedirector`, which discovers mixer handles through reflection since `BassAudioMixer` is internal to the framework. -| [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) | -|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------- | ------------- | ------------- | +**Toggle:** Settings → Graphics → Android Performance → *Low-latency audio (Oboe)* -You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). +### 🎮 Custom input handlers -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. +Upstream relies on the framework's default touch handling. This fork adds three dedicated input handlers with direct event dispatch and unbuffered input: -**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. +#### Samsung S Pen / stylus (`AndroidStylusHandler`) +- Full **tablet area mapping** — the S Pen digitiser maps to the game area like a Wacom tablet +- Pressure-sensitive clicking with configurable threshold +- S Pen button → right-click, eraser → middle-click +- Dynamic area expansion if the digitiser reports out-of-bounds coordinates +- Rotation support for different device orientations -## Developing a custom ruleset +#### Physical keyboard (`AndroidKeyboardHandler`) +- Complete Android keycode → osuTK key mapping (A–Z, 0–9, F1–F12, special keys) +- Uses `FrozenDictionary` for O(1) lookup in the hot path +- Filters system keys (Back, Home, Volume) to avoid interfering with Android -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). +#### Mouse / trackpad (`AndroidMouseHandler`) +- Full mouse support (position, scroll wheel, 5 buttons) for Samsung DeX and USB mice +- Processes historical motion events for accurate input timing +- System pointer icon hidden to prevent double cursors in DeX mode -You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096). +### ⚡ Performance tuning -## Developing osu! +| Optimisation | What it does | +|---|---| +| **CPU affinity pinning** | Pins update, render, input, and audio threads to high-performance (big) CPU cores. Uses sysfs topology detection (`/sys/devices/system/cpu/cpuN/cpufreq/cpuinfo_max_freq`) to correctly identify Prime + Gold cores across Snapdragon, Exynos, Dimensity, and Tensor SoCs. | +| **Thread priority** | Sets game threads to `UrgentDisplay` priority (-8) for minimum scheduling latency. | +| **GC tuning** | Switches to `SustainedLowLatency` GC mode during gameplay to avoid collection pauses. | +| **Sustained performance mode** | Always-on `Window.SetSustainedPerformanceMode(true)` prevents thermal throttling from causing sudden FPS drops. | +| **ADPF integration** | Native ADPF session creation and work duration reporting for Android's Dynamic Performance Framework. | +| **Display refresh rate** | Queries all supported display modes, auto-selects the highest refresh rate, and sets `Surface.SetFrameRate()` hints for the compositor. Supports 120 Hz+ panels. | -### Prerequisites +**Toggle:** Settings → Graphics → Android Performance → *Performance mode* -Please make sure you have the following prerequisites: +### 🖥️ Samsung DeX support -- A desktop platform with the [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) installed (this fork targets .NET 10; upstream ppy/osu uses .NET 8). +When connected to an external monitor via DeX: -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. +- Auto-detects DeX mode (`UiMode.TypeDesk`) +- Auto-enables performance mode and immersive fullscreen +- Queries external display modes and selects the highest refresh rate +- Starts a permanent high-performance GC session +- Mouse/keyboard input works seamlessly (including mouse back button → Escape) -### Downloading the source code +### 🎨 Vulkan GPU probing -Clone the repository: +A native C++ Vulkan probe (`vulkan_bridge.cpp`) checks the GPU's capabilities at startup: -```shell -git clone https://github.com/ppy/osu -cd osu -``` +- Vulkan API version and driver info +- Device-local VRAM +- Modern extensions: dynamic rendering, synchronisation2, graphics pipeline library, shader objects, present ID/wait +- GPU-specific workaround detection (disables problematic features on known-bad drivers) +- Result exposed as `IsVulkanRecommended` — the Vulkan renderer option only appears in settings if the GPU actually supports it -To update the source code to the latest commit, run the following command inside the `osu` directory: +**Toggle:** Settings → Graphics → Android Performance → *GPU detection (Vulkan)* -```shell -git pull -``` +### 📦 Build system -### Building +| Change | Detail | +|---|---| +| **.NET 10** | Upgraded from .NET 8 (upstream) to .NET 10 for latest runtime improvements | +| **Framework submodule** | Uses [winnerspiros/osu-framework](https://github.com/winnerspiros/osu-framework) as a git submodule instead of the NuGet package, enabling mobile platform modifications | +| **Profiled AOT** | `AndroidEnableProfiledAot=true` for faster startup (startup-critical methods are ahead-of-time compiled) | +| **IL trimming** | Partial trimming enabled for smaller APK size | +| **LZ4 compression** | Assembly compression reduces APK size by ~20 MB | +| **Native C++ library** | `libosu_native.so` built with NDK r29, C++20, `-O3`, LTO, and 16 KB page alignment (`-Wl,-z,max-page-size=16384`) for Android 15+ compatibility | +| **ELF page alignment** | Custom MSBuild task (`PatchElfPageSize.targets`) rewrites 4 KB-aligned .so files to 16 KB for Android 16 (API 36+) compliance | +| **arm64 only** | Single ABI target reduces APK size and build complexity | -#### From an IDE +### 📱 Android integration -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: +| Feature | Detail | +|---|---| +| **File associations** | Opens `.osz` (beatmaps), `.osk` (skins), `.osr` (replays) and `osu://` / `osump://` URLs | +| **Samsung Game Launcher** | Registered via `com.samsung.android.game.biz` metadata for Samsung Game Booster optimisations | +| **Samsung MultiDisplay** | `keep_process_alive` flag prevents process termination on display transitions | +| **Orientation management** | Locks to landscape during gameplay, allows portrait in menus (phone only — tablets stay landscape) | +| **Tablet detection** | Devices with smallest screen width ≥ 600 dp are treated as tablets | +| **Update notifications** | Checks GitHub Releases for newer versions and notifies the user | +| **Notch/cutout support** | `LayoutInDisplayCutoutMode.ShortEdges` uses the full display area | +| **Min SDK 33** | Targets Android 13+ (API 33) for modern API access; target SDK 36 | -- `osu.Desktop.slnf` (most common) -- `osu.Android.slnf` -- `osu.iOS.slnf` +### 🔧 CI/CD -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). +| Workflow | What it does | +|---|---| +| `release.yml` | **One-click APK builder.** Compiles native C++, builds the .NET project, signs the APK, creates a GitHub Release. Auto-generates a signing keystore if no secrets are configured. | +| `generate-keystore.yml` | Helper to generate a persistent signing keystore for consistent APK signatures across builds. | +| `ci.yml` | Full CI with desktop tests + Android/iOS compile-only verification. | -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. +### 🔧 osu-framework fork ([winnerspiros/osu-framework](https://github.com/winnerspiros/osu-framework)) -#### From CLI +The upstream ppy/osu uses the official `ppy.osu.Framework` NuGet package. This fork replaces it with a git submodule pointing to a custom framework fork — enabling deep platform-level changes that aren't possible through the public API. -You can also build and run *osu!* from the command-line with a single command: +#### .NET 10 upgrade -```shell -dotnet run --project osu.Desktop -``` +The entire framework is upgraded from .NET 8 → **.NET 10** with C# 14 language features. All target frameworks are updated (`net10.0`, `net10.0-android`, `net10.0-ios`). -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). +#### Audio latency reduction -If the build fails, try to restore NuGet packages with `dotnet restore`. +The biggest audio change lives in the framework's `AudioManager`: -#### Building for Android +| Setting | Upstream (ppy) | This fork | +|---------|----------------|-----------| +| `Bass.DeviceBufferLength` | Default (10 ms) | **5 ms** | +| `Bass.PlaybackBufferLength` | Default (100 ms) | **25 ms** (Android), **30 ms** (iOS) | +| `Bass.UpdatePeriod` | Default (5 ms) | **2 ms** (Android), **3 ms** (iOS) | +| AAudio backend | Not enabled | **Enabled** via `Bass.Configure(67, 1)` | +| Sample rate | 44100 Hz | **48000 Hz** (native rate for AAudio and CoreAudio) | -**Prerequisites:** -- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (this fork targets .NET 10) -- JDK 17 (`sudo apt install openjdk-17-jdk` or use [Microsoft's JDK](https://learn.microsoft.com/en-us/java/openjdk/download)) -- Android workload: `dotnet workload install android` +The `BassAudioMixer.Handle` property is made **public** so the Android Oboe bridge can access mixer handles directly instead of using fragile reflection. -**Debug build** (auto-signed with debug keystore, suitable for local testing): +#### Android Vulkan as primary renderer -```shell -dotnet build -c Debug osu.Android/osu.Android.csproj -``` +In the framework's `GameHost`, the renderer order for Android is changed: -The APK will be at `osu.Android/bin/Debug/net10.0-android/sh.ppy.osulazer.apk`. Debug builds are always signed with the Android debug keystore and can be installed directly via `adb install`. +- **Upstream:** OpenGL only +- **This fork:** Vulkan (primary) → OpenGL (fallback) -**Release build** (optimised with AOT, trimming, and compression): +A diagnostic check logs a warning if the device has Vulkan < 1.3, since osu! Veldrid uses Vulkan 1.3 features (dynamic rendering, synchronisation2). -```shell -dotnet publish -c Release osu.Android/osu.Android.csproj -f net10.0-android -``` +#### Android platform layer -The APK will be at `osu.Android/bin/Release/net10.0-android/publish/sh.ppy.osulazer.apk`. +The framework fork has a full `osu.Framework.Android` project with: +- `AndroidGameActivity` / `AndroidGameHost` — Activity lifecycle and host integration +- `AndroidStorage` — Content resolver file access +- `AndroidFileSelector` — Native file picker +- Release build optimisations: profiled AOT, LLVM, partial trimming, IL stripping -**Signing the Release APK:** +#### iOS platform layer -Release APKs may not be automatically signed by the .NET SDK. If you get `INSTALL_PARSE_FAILED_NO_CERTIFICATES` when installing, sign the APK manually: +A complete `osu.Framework.iOS` project with: +- `IOSGameHost` / `IOSWindow` — iOS host and Metal-backed window +- AOT compilation with Mono interpreter fallback +- Native framework references for BASS, FFmpeg, and Metal +- macOS-only framework stripping (removes ApplicationServices/Quartz from iOS linker) -```shell -# Find your build-tools (adjust version as needed) -BUILD_TOOLS="$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | sort -V | tail -1)" - -# Zipalign (required before signing) -"$BUILD_TOOLS/zipalign" -f -p 4 sh.ppy.osulazer.apk sh.ppy.osulazer-aligned.apk -mv sh.ppy.osulazer-aligned.apk sh.ppy.osulazer.apk - -# Sign with debug keystore (or your own release keystore) -"$BUILD_TOOLS/apksigner" sign \ - --ks ~/.android/debug.keystore \ - --ks-pass pass:android \ - --key-pass pass:android \ - --ks-key-alias androiddebugkey \ - sh.ppy.osulazer.apk - -# Verify -"$BUILD_TOOLS/apksigner" verify sh.ppy.osulazer.apk -``` +#### Performance hot-path optimisations + +Several commits eliminate allocations and reduce lock contention in the framework: + +- **LINQ elimination** in `Dropdown.cs`, `FlowContainer.cs`, and shader pipelines — removed redundant enumerations that allocated on every keyboard event or layout pass +- **`System.Threading.Lock`** migration — replaced `lock(object)` with the modern `Lock` type in `RendererDisposalQueue`, `SampleStore`, `VeldridTexture`, `GLTexture`, and others for lower-overhead synchronisation +- **GL state thrashing reduction** — avoids redundant OpenGL state changes in the renderer +- **Texture upload pipeline** optimisations for faster asset loading on mobile + +#### Dependency updates + +Key packages updated beyond upstream versions: + +| Package | Upstream | Fork | +|---------|----------|------| +| `ppy.SDL3-CS` | 2026.302.0 | 2026.320.0 | +| `SixLabors.ImageSharp` | 3.1.11 | 3.1.12 | +| `Newtonsoft.Json` | 13.0.3 | 13.0.4 | +| `JetBrains.Annotations` | 2023.3.0 | 2025.2.4 | +| `StbiSharp` | 1.1.0 | 1.2.1 | +| `Xamarin.AndroidX.Window` | 1.2.0.1 | 1.5.1.2 | + +### 🖥️ Veldrid fork ([winnerspiros/veldrid](https://github.com/winnerspiros/veldrid)) + +[Veldrid](https://github.com/veldrid/veldrid) is the cross-platform GPU abstraction layer used by osu-framework. Upstream ppy/osu uses it via a NuGet package (`ppy.Veldrid`). This fork replaces that with a git submodule containing a customised Veldrid with Android graphics support and performance improvements. + +#### Android Vulkan rendering + +The main reason this fork exists — full Vulkan support on Android: + +- **`VkSurfaceUtil.cs`** — Creates Vulkan surfaces from `ANativeWindow` via `VK_KHR_android_surface` +- **`VkGraphicsDevice.cs`** — Detects and enables Android-specific Vulkan extensions +- **`AndroidRuntime.cs`** — P/Invoke bindings to `ANativeWindow_fromSurface()`, `ANativeWindow_setBuffersGeometry()`, `ANativeWindow_release()` +- **`SwapchainSource.cs`** — `AndroidSurfaceSwapchainSource` class for passing native windows to Vulkan + +#### OpenGL ES support + +For devices where Vulkan isn't available: + +- **EGL bindings** (`EGLNative.cs`) — Complete EGL 1.4 API for OpenGL ES 2.0/3.0 context creation +- **`OpenGLGraphicsDevice.cs`** — `initializeANativeWindow()` for Android surface initialisation via EGL +- **GLES stencil fixes** — Proper stencil buffer initialisation (critical for osu!'s UI rendering) -If `~/.android/debug.keystore` does not exist, generate it: +#### .NET 10 and performance + +- Upgraded to `net10.0` across all projects +- **`System.Threading.Lock`** migration across all backends (D3D11, Vulkan, OpenGL, Metal) — 11 files updated +- **UTF-8 string literals** (`"vkCreate..."u8`) for zero-allocation Vulkan function lookups +- **Vulkan fence early-out** — uses `vkGetFenceStatus()` to avoid blocking waits +- **Screen tearing support** — `AllowTearing` property in `VkSwapchain` for lowest-latency present modes +- **D3D11 platform annotations** — `[SupportedOSPlatform("windows")]` enables safe trimming on non-Windows platforms + +--- + +## Download + +Grab the latest signed APK from the [Releases page](https://github.com/winnerspiros/osu/releases/latest). + +**Requirements:** +- Android 13 or later (API 33+) +- arm64 device (virtually all modern Android phones and tablets) + +Install by opening the APK on your device. You may need to enable "Install from unknown sources" in your device settings. + +--- + +## Building from source + +### Prerequisites + +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- JDK 17 (`sudo apt install openjdk-17-jdk` or [Microsoft's JDK](https://learn.microsoft.com/en-us/java/openjdk/download)) +- Android workload: `dotnet workload install android` +- Android NDK r29 + CMake (for native library — only needed for release builds) + +### Clone ```shell -keytool -genkeypair -v -keystore ~/.android/debug.keystore \ - -storepass android -keypass android -alias androiddebugkey \ - -keyalg RSA -keysize 2048 -validity 10000 \ - -dname "CN=Android Debug,O=Android,C=US" +git clone --recurse-submodules https://github.com/winnerspiros/osu +cd osu ``` -**Install via ADB:** +### Debug build (quick iteration) ```shell -adb install sh.ppy.osulazer.apk +dotnet build -c Debug osu.Android/osu.Android.csproj +adb install osu.Android/bin/Debug/net10.0-android/sh.ppy.osulazer.apk ``` -### Testing with resource/framework modifications +Debug builds skip AOT/trimming and use the Android debug keystore — fast to build, fine for testing. + +### Release build (optimised) -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: +The easiest way is the GitHub Actions workflow — just click **Actions → Build Android APK → Run workflow**. It handles NDK setup, native compilation, signing, and creates a Release automatically. -Windows: +To build locally: -```ps -UseLocalFramework.ps1 -UseLocalResources.ps1 +```shell +# 1. Build native library (requires NDK r29) +NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" +CMAKE_BIN="$ANDROID_HOME/cmake/3.22.1/bin/cmake" + +"$CMAKE_BIN" -B build-native/arm64-v8a -S osu.Android/Native \ + -DCMAKE_TOOLCHAIN_FILE="$NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-33 -DCMAKE_BUILD_TYPE=Release + +"$CMAKE_BIN" --build build-native/arm64-v8a --config Release -j $(nproc) +mkdir -p osu.Android/libs/arm64-v8a +cp build-native/arm64-v8a/libosu_native.so osu.Android/libs/arm64-v8a/ + +# 2. Build and publish the APK +dotnet publish -c Release osu.Android/osu.Android.csproj -f net10.0-android ``` -macOS / Linux: +### Desktop build -```ps -UseLocalFramework.sh -UseLocalResources.sh +```shell +dotnet run --project osu.Desktop ``` -Note that these commands assume you have the relevant project(s) checked out in adjacent directories: +Load `osu.Desktop.slnf` in your IDE for desktop development, or `osu.Android.slnf` for Android. + +--- + +## Project structure (fork-specific files) ``` -|- osu // this repository -|- osu-framework -|- osu-resources +osu.Android/ +├── Native/ +│ ├── oboe_bridge.cpp/h # C++ Oboe audio bridge (AAudio/OpenSL ES) +│ ├── vulkan_bridge.cpp/h # C++ Vulkan GPU capability probe +│ ├── OboeAudioBridge.cs # P/Invoke wrapper for Oboe +│ ├── VulkanProbe.cs # P/Invoke wrapper for Vulkan +│ └── CMakeLists.txt # NDK build config (C++20, Oboe, Vulkan) +├── Input/ +│ ├── AndroidStylusHandler.cs # S Pen / stylus tablet-area input +│ ├── AndroidKeyboardHandler.cs +│ └── AndroidMouseHandler.cs +├── Performance/ +│ └── AndroidHighPerformanceSessionManager.cs +├── OboeAudioRedirector.cs # BASS → Oboe audio routing +├── AndroidNativeBridgeManager.cs +├── OsuGameAndroid.cs # Main game class (Android lifecycle, perf, DeX) +├── OsuGameActivity.cs # Activity (intents, surface, input dispatch) +└── AndroidManifest.xml # Samsung tags, file associations, API levels + +build/ +├── PatchElfPageSize.targets # ELF 4KB→16KB alignment for Android 16+ +└── SuppressSubmoduleWarnings.targets + +.github/workflows/ +├── release.yml # One-click APK builder + GitHub Release +├── generate-keystore.yml # Signing keystore generator +└── ci.yml # CI with Android/iOS compile jobs + +osu.Game/ +├── Configuration/OsuConfigManager.cs # +3 Android settings +├── OsuGameBase.cs # +virtual props (Vulkan, Oboe, refresh rate) +├── Overlays/Settings/.../AndroidPerformanceSettings.cs # Android settings UI +├── Overlays/Settings/.../RendererSettings.cs # +Vulkan dropdown on Android +├── Utils/MobileUtils.cs # Orientation management +└── Updater/MobileUpdateNotifier.cs # GitHub Release update checker ``` +--- + +## Developing osu! + ### Code analysis 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. @@ -194,14 +352,10 @@ JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it ## Contributing -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. +Contributions are welcome! Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in the most effective way possible. 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. - -Our team believes in **human contributions**. Any contribution – be it an issue report or a pull request – which is created by, documented by, or aided by AI/LLM usage will typically be **closed and locked without further discussion**. - ## Licence *osu!*'s code and framework are licensed under the [MIT licence](https://opensource.org/licenses/MIT). Please see [the licence file](LICENCE) for more information. [tl;dr](https://tldrlegal.com/license/mit-license) you can do whatever you want as long as you include the original copyright and license notice in any copy of the software/source. @@ -209,3 +363,7 @@ Our team believes in **human contributions**. Any contribution – be it an issu Please note that this *does not cover* the usage of the "osu!" or "ppy" branding in any software, resources, advertising or promotion, as this is protected by trademark law. Please also note that game resources are covered by a separate licence. Please see the [ppy/osu-resources](https://github.com/ppy/osu-resources) repository for clarifications. + +## Credits + +This fork is based on [ppy/osu](https://github.com/ppy/osu) by Dean Herbert (peppy) and contributors. All upstream code is under the MIT licence. diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 01a1fd6913f3..4b44e1ca2e87 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -171,6 +171,17 @@ bool OboeBridge::start() { active_.store(true); affinitySet_.store(false); + + // Eagerly compute and cache the big-core mask BEFORE the audio callback runs. + // This ensures computeBigCoreMask() (which does file I/O via fopen on sysfs) + // never executes on the real-time audio thread where it could cause latency + // spikes or priority inversion. + int mask = cachedBigCoreMask.load(std::memory_order_relaxed); + if (mask < 0) { + mask = computeBigCoreMask(); + cachedBigCoreMask.store(mask, std::memory_order_relaxed); + } + LOGI("Oboe stream started"); return true; } @@ -272,9 +283,20 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( // Uses sysfs-based topology detection for accurate big-core identification // across all SoC vendors (Snapdragon, Exynos, Dimensity, Tensor). if (!affinitySet_.load(std::memory_order_relaxed)) { + // cachedBigCoreMask is eagerly computed in start(), so this load + // should always return >= 0. The < 0 branch is a defensive fallback + // that avoids file I/O — it uses the generic upper-half heuristic + // instead of calling computeBigCoreMask() on the audio thread. int bigMask = cachedBigCoreMask.load(std::memory_order_relaxed); if (bigMask < 0) { - bigMask = computeBigCoreMask(); + // Defensive: sysfs was never read (should not happen). + // Use upper-half heuristic instead of doing file I/O here. + int num_cores = sysconf(_SC_NPROCESSORS_CONF); + bigMask = 0; + if (num_cores > 1) { + for (int i = num_cores / 2; i < std::min(num_cores, 32); ++i) + bigMask |= (1 << i); + } cachedBigCoreMask.store(bigMask, std::memory_order_relaxed); } diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index c2d127b609d6..4d69353f8d16 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -483,15 +483,22 @@ public void SelectHighestRefreshRate() Schedule(() => { - AvailableDisplayRefreshRates.Clear(); - AvailableDisplayRefreshRates.Add(0); // 0 = "Auto (highest)" - AvailableDisplayRefreshRates.AddRange(rates); - - // If user hasn't selected a rate, auto-select highest. - if (SelectedDisplayRefreshRate.Value == 0) - applyDisplayMode(display, modes.OrderByDescending(m => m.RefreshRate).First()); - else - applyRefreshRate(SelectedDisplayRefreshRate.Value); + try + { + AvailableDisplayRefreshRates.Clear(); + AvailableDisplayRefreshRates.Add(0); // 0 = "Auto (highest)" + AvailableDisplayRefreshRates.AddRange(rates); + + // If user hasn't selected a rate, auto-select highest. + if (SelectedDisplayRefreshRate.Value == 0) + applyDisplayMode(display, modes.OrderByDescending(m => m.RefreshRate).First()); + else + applyRefreshRate(SelectedDisplayRefreshRate.Value); + } + catch (Exception ex) + { + Debug.WriteLine($"[osu!] Failed to apply initial display mode: {ex.Message}"); + } }); Logger.Log($"[osu!] Display modes queried: {string.Join(", ", rates.Select(r => $"{r}Hz"))} (DeX={gameActivity.IsDeX})", LoggingTarget.Performance);