diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f8ce3eea2c..1771459a878b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,8 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: "10.0.x" + - name: Set Xcode version + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Install .NET Workloads run: dotnet workload install ios diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63b69ac6b607..81c2e6ccc71d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ name: Build Android APK on: push: tags: - - "*.*.*" - - "!*-*" + - '*.*.*' + - '!*-*' workflow_dispatch: jobs: @@ -31,9 +31,6 @@ jobs: with: dotnet-version: "10.0.x" - - name: Update .NET workloads - run: dotnet workload update - - name: Install .NET Android workload run: dotnet workload install android @@ -91,49 +88,38 @@ jobs: - name: Build Android APK (signed) if: steps.keystore.outputs.has_keystore == 'true' - env: - ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} - KEYPASS: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} - STOREPASS: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} - 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:AndroidKeyStore="true" \ - -p:AndroidSigningKeyStore="${{ github.workspace }}/osu.Android/osu.keystore" \ - -p:AndroidSigningKeyAlias="${ALIAS}" \ - -p:AndroidSigningKeyPass="${KEYPASS}" \ - -p:AndroidSigningStorePass="${STOREPASS}" + 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:AndroidKeyStore=true + -p:AndroidSigningKeyStore=${{ github.workspace }}/osu.Android/osu.keystore + -p:AndroidSigningKeyAlias=${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + -p:AndroidSigningKeyPass=${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + -p:AndroidSigningStorePass=${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} - name: Build Android APK (unsigned) 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:AndroidKeyStore="false" + 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:AndroidKeyStore=false - name: Find APK id: find_apk run: | - # Strictly prioritize the publish/ directory where the signed APK resides PUBLISH_DIR="osu.Android/bin/Release/net10.0-android/publish" - echo "Searching for APK in: $PUBLISH_DIR" APK=$(find "$PUBLISH_DIR" -maxdepth 1 -name "*.apk" 2>/dev/null | head -1) - if [ -z "$APK" ]; then - echo "::error::Signed APK not found in publish directory ($PUBLISH_DIR)!" - # Use a non-restricted way to fail - sh -c "false" + APK=$(find osu.Android/bin/Release -name "*.apk" | head -1) fi - - echo "Final APK found at: $APK" echo "apk_path=$APK" >> "$GITHUB_OUTPUT" - name: Upload APK artifact diff --git a/build/PatchElfPageSize.targets b/build/PatchElfPageSize.targets index 815e1b4aacd6..9be15f4da475 100644 --- a/build/PatchElfPageSize.targets +++ b/build/PatchElfPageSize.targets @@ -1,3 +1,17 @@ + @@ -7,131 +21,250 @@ 0 ? TargetPageSize : 0x4000; +WasPatched = false; +int pageSize = TargetPageSize > 0 ? TargetPageSize : 0x4000; // 16 KB default + +if (!System.IO.File.Exists(FilePath)) + return true; + +byte[] data = System.IO.File.ReadAllBytes(FilePath); + +// ── Validate ELF magic ────────────────────────────────────────────── +if (data.Length < 64 || data[0] != 0x7F || data[1] != (byte)'E' || data[2] != (byte)'L' || data[3] != (byte)'F') + return true; + +int eiClass = data[4]; // 1 = 32-bit, 2 = 64-bit +bool is64 = eiClass == 2; +bool isLE = data[5] == 1; + +// Only 64-bit little-endian ELFs need 16 KB alignment (Android arm64/x64). +if (!is64 || !isLE) + return true; + +// ── Helper lambdas ────────────────────────────────────────────────── +System.Func RU64 = (b, o) => + (ulong)b[o] | ((ulong)b[o+1] << 8) | ((ulong)b[o+2] << 16) | ((ulong)b[o+3] << 24) | + ((ulong)b[o+4] << 32) | ((ulong)b[o+5] << 40) | ((ulong)b[o+6] << 48) | ((ulong)b[o+7] << 56); + +System.Func RU32 = (b, o) => + (uint)b[o] | ((uint)b[o+1] << 8) | ((uint)b[o+2] << 16) | ((uint)b[o+3] << 24); + +System.Action WU64 = (b, o, v) => +{ + b[o] = (byte)(v); b[o+1] = (byte)(v >> 8); + b[o+2] = (byte)(v >> 16); b[o+3] = (byte)(v >> 24); + b[o+4] = (byte)(v >> 32); b[o+5] = (byte)(v >> 40); + b[o+6] = (byte)(v >> 48); b[o+7] = (byte)(v >> 56); +}; + +// ── Parse ELF header ──────────────────────────────────────────────── +ulong e_phoff = RU64(data, 32); +ulong e_shoff = RU64(data, 40); +int e_phentsize = (int)(data[54] | (data[55] << 8)); +int e_phnum = (int)(data[56] | (data[57] << 8)); +int e_shentsize = (int)(data[58] | (data[59] << 8)); +int e_shnum = (int)(data[60] | (data[61] << 8)); + +// ── Collect LOAD segments ─────────────────────────────────────────── +const uint PT_LOAD = 1; +bool alreadyAligned = true; +var loadIndices = new System.Collections.Generic.List(); + +for (int i = 0; i < e_phnum; i++) +{ + int hdr = (int)e_phoff + i * e_phentsize; + if (RU32(data, hdr) == PT_LOAD) + { + loadIndices.Add(i); + if (RU64(data, hdr + 48) < (ulong)pageSize) + alreadyAligned = false; + } +} + +if (alreadyAligned) + return true; + +// ── Build a new file with proper padding ──────────────────────────── +var segInfos = new System.Collections.Generic.List>(); +foreach (int idx in loadIndices) +{ + int hdr = (int)e_phoff + idx * e_phentsize; + segInfos.Add(System.Tuple.Create(idx, + RU64(data, hdr + 8), // p_offset + RU64(data, hdr + 32), // p_filesz + RU64(data, hdr + 16) // p_vaddr + )); +} +segInfos.Sort((a, b) => a.Item2.CompareTo(b.Item2)); + +var ms = new System.IO.MemoryStream(data.Length + pageSize * loadIndices.Count); +int currentOld = 0; +var deltas = new System.Collections.Generic.List>(); + +foreach (var seg in segInfos) +{ + int oldOff = (int)seg.Item2; + ulong vaddr = seg.Item4; + int filesz = (int)seg.Item3; + + if (oldOff > currentOld) + ms.Write(data, currentOld, oldOff - currentOld); + + long newOff = ms.Position; + ulong requiredMod = vaddr % (ulong)pageSize; + ulong currentMod = (ulong)newOff % (ulong)pageSize; + + if (currentMod != requiredMod) + { + long pad = (requiredMod >= currentMod) + ? (long)(requiredMod - currentMod) + : (long)((ulong)pageSize - currentMod + requiredMod); + for (long p = 0; p < pad; p++) ms.WriteByte(0); + newOff = ms.Position; + } + + deltas.Add(System.Tuple.Create((ulong)oldOff, newOff - oldOff)); + ms.Write(data, oldOff, filesz); + currentOld = oldOff + filesz; +} + +if (currentOld < data.Length) + ms.Write(data, currentOld, data.Length - currentOld); - if (System.IO.File.Exists(FilePath)) +byte[] newData = ms.ToArray(); + +// ── Offset translation helper ─────────────────────────────────────── +System.Func translate = (ulong old) => +{ + long d = 0; + foreach (var t in deltas) + { + if (old >= t.Item1) d = t.Item2; + else break; + } + return (ulong)((long)old + d); +}; + +// ── Patch ELF header: e_shoff ─────────────────────────────────────── +ulong newShoff = translate(e_shoff); +WU64(newData, 40, newShoff); + +// ── Patch program headers ─────────────────────────────────────────── +for (int i = 0; i < e_phnum; i++) +{ + int oldHdr = (int)e_phoff + i * e_phentsize; + int newHdr = (int)translate((ulong)oldHdr); + + WU64(newData, newHdr + 8, translate(RU64(data, oldHdr + 8))); // p_offset + + if (RU32(data, oldHdr) == PT_LOAD) + WU64(newData, newHdr + 48, (ulong)pageSize); // p_align +} + +// ── Patch section headers ─────────────────────────────────────────── +for (int i = 0; i < e_shnum; i++) +{ + int oldSh = (int)e_shoff + i * e_shentsize; + int newSh = (int)newShoff + i * e_shentsize; + WU64(newData, newSh + 24, translate(RU64(data, oldSh + 24))); // sh_offset +} + +// ── Write patched file (with lock to handle parallel builds) ──────── +string lockFile = FilePath + ".patch-lock"; +System.IO.FileStream lockStream = null; +try +{ + // Acquire an exclusive lock file to prevent concurrent writes. + // Retry up to 30 times with 1-second delays (handles parallel MSBuild nodes). + for (int attempt = 0; attempt < 30; attempt++) + { + try + { + lockStream = new System.IO.FileStream(lockFile, + System.IO.FileMode.OpenOrCreate, System.IO.FileAccess.ReadWrite, + System.IO.FileShare.None); + break; + } + catch (System.IO.IOException) { - string lockFile = FilePath + ".patch-lock"; - System.IO.FileStream lockStream = null; + System.Threading.Thread.Sleep(1000); + } + } - try - { - for (int attempt = 0; attempt < 120; attempt++) - { - try { lockStream = new System.IO.FileStream(lockFile, System.IO.FileMode.OpenOrCreate, System.IO.FileAccess.ReadWrite, System.IO.FileShare.None); break; } - catch (System.IO.IOException) { System.Threading.Thread.Sleep(1000); } - } - - if (lockStream != null && System.IO.File.Exists(FilePath)) - { - byte[] data = null; - for (int attempt = 0; attempt < 10; attempt++) - { - try { data = System.IO.File.ReadAllBytes(FilePath); break; } - catch (System.IO.IOException) { System.Threading.Thread.Sleep(1000); } - } - - if (data != null && data.Length >= 64 && data[0] == 0x7F && data[1] == (byte)'E' && data[2] == (byte)'L' && data[3] == (byte)'F' && data[4] == 2 && data[5] == 1) - { - System.Func RU64 = (b, o) => (ulong)b[o] | ((ulong)b[o+1] << 8) | ((ulong)b[o+2] << 16) | ((ulong)b[o+3] << 24) | ((ulong)b[o+4] << 32) | ((ulong)b[o+5] << 40) | ((ulong)b[o+6] << 48) | ((ulong)b[o+7] << 56); - System.Func RU32 = (b, o) => (uint)b[o] | ((uint)b[o+1] << 8) | ((uint)b[o+2] << 16) | ((uint)b[o+3] << 24); - System.Action WU64 = (b, o, v) => { b[o]=(byte)v; b[o+1]=(byte)(v>>8); b[o+2]=(byte)(v>>16); b[o+3]=(byte)(v>>24); b[o+4]=(byte)(v>>32); b[o+5]=(byte)(v>>40); b[o+6]=(byte)(v>>48); b[o+7]=(byte)(v>>56); }; - - ulong e_phoff = RU64(data, 32); - int e_phentsize = (int)(data[54] | (data[55] << 8)); - int e_phnum = (int)(data[56] | (data[57] << 8)); - ulong e_shoff = RU64(data, 40); - int e_shentsize = (int)(data[58] | (data[59] << 8)); - int e_shnum = (int)(data[60] | (data[61] << 8)); - - const uint PT_LOAD = 1; - bool needsPatch = false; - for (int i = 0; i < e_phnum; i++) - { - int hdr = (int)e_phoff + i * e_phentsize; - if (RU32(data, hdr) == PT_LOAD && RU64(data, hdr + 48) < (ulong)pageSize) { needsPatch = true; break; } - } - - if (needsPatch) - { - var segInfos = new System.Collections.Generic.List>(); - var loadIndices = new System.Collections.Generic.List(); - for (int i = 0; i < e_phnum; i++) - { - int hdr = (int)e_phoff + i * e_phentsize; - if (RU32(data, hdr) == PT_LOAD) - { - loadIndices.Add(i); - segInfos.Add(System.Tuple.Create(i, RU64(data, hdr + 8), RU64(data, hdr + 32), RU64(data, hdr + 16))); - } - } - segInfos.Sort((a, b) => a.Item2.CompareTo(b.Item2)); - - var ms = new System.IO.MemoryStream(data.Length + pageSize * loadIndices.Count); - int currentOld = 0; - var deltas = new System.Collections.Generic.List>(); - - foreach (var seg in segInfos) - { - int oldOff = (int)seg.Item2; - ulong vaddr = seg.Item4; - int filesz = (int)seg.Item3; - if (oldOff > currentOld) ms.Write(data, currentOld, oldOff - currentOld); - long newOff = ms.Position; - ulong requiredMod = vaddr % (ulong)pageSize; - ulong currentMod = (ulong)newOff % (ulong)pageSize; - if (currentMod != requiredMod) - { - long pad = (requiredMod >= currentMod) ? (long)(requiredMod - currentMod) : (long)((ulong)pageSize - currentMod + requiredMod); - for (long p = 0; p < pad; p++) ms.WriteByte(0); - newOff = ms.Position; - } - deltas.Add(System.Tuple.Create((ulong)oldOff, newOff - oldOff)); - ms.Write(data, oldOff, filesz); - currentOld = oldOff + filesz; - } - if (currentOld < data.Length) ms.Write(data, currentOld, data.Length - currentOld); - byte[] newData = ms.ToArray(); - - System.Func translate = (ulong old) => { long d = 0; foreach (var t in deltas) { if (old >= t.Item1) d = t.Item2; else break; } return (ulong)((long)old + d); }; - - ulong newShoff = translate(e_shoff); - WU64(newData, 40, newShoff); - - for (int i = 0; i < e_phnum; i++) - { - int oldHdr = (int)e_phoff + i * e_phentsize; - int newHdr = (int)translate((ulong)oldHdr); - WU64(newData, newHdr + 8, translate(RU64(data, oldHdr + 8))); - if (RU32(data, oldHdr) == PT_LOAD) WU64(newData, newHdr + 48, (ulong)pageSize); - } - - for (int i = 0; i < e_shnum; i++) - { - int oldSh = (int)e_shoff + i * e_shentsize; - int newSh = (int)newShoff + i * e_shentsize; - WU64(newData, newSh + 24, translate(RU64(data, oldSh + 24))); - } - - for (int attempt = 0; attempt < 20; attempt++) - { - try { System.IO.File.WriteAllBytes(FilePath, newData); WasPatched = true; break; } - catch (System.IO.IOException) { System.Threading.Thread.Sleep(1000); } - } - if (WasPatched) Log.LogMessage(MessageImportance.High, "PatchElfPageSize: successfully patched {0}", System.IO.Path.GetFileName(FilePath)); - } - } - } - } - finally + if (lockStream == null) + { + Log.LogMessage(MessageImportance.High, + "PatchElfPageSize: could not acquire lock for {0}, skipping (another build node may be patching it)", + System.IO.Path.GetFileName(FilePath)); + return true; + } + + // Re-read and re-check: another node may have already patched while we waited. + byte[] freshData = System.IO.File.ReadAllBytes(FilePath); + bool stillNeeds = false; + if (freshData.Length >= 64 && freshData[0] == 0x7F && freshData[4] == 2 && freshData[5] == 1) + { + ulong freshPhoff = RU64(freshData, 32); + int freshPhentsz = (int)(freshData[54] | (freshData[55] << 8)); + int freshPhnum = (int)(freshData[56] | (freshData[57] << 8)); + for (int i = 0; i < freshPhnum; i++) + { + int h = (int)freshPhoff + i * freshPhentsz; + if (RU32(freshData, h) == PT_LOAD && RU64(freshData, h + 48) < (ulong)pageSize) { - if (lockStream != null) { lockStream.Dispose(); try { System.IO.File.Delete(lockFile); } catch { } } + stillNeeds = true; + break; } } - ]]> + } + + if (!stillNeeds) + { + Log.LogMessage(MessageImportance.Low, + "PatchElfPageSize: {0} was already patched by another build node", + System.IO.Path.GetFileName(FilePath)); + return true; + } + + // Even with a lock file, the actual .so may be locked for reading by another MSBuild process. + // Retry writing the actual file. + for (int attempt = 0; attempt < 30; attempt++) + { + try + { + System.IO.File.WriteAllBytes(FilePath, newData); + WasPatched = true; + break; + } + catch (System.IO.IOException) + { + if (attempt == 29) throw; + System.Threading.Thread.Sleep(1000); + } + } + WasPatched = true; + Log.LogMessage(MessageImportance.High, + "PatchElfPageSize: patched {0} (align 0x1000 -> 0x{1:X}, +{2} bytes)", + System.IO.Path.GetFileName(FilePath), pageSize, newData.Length - data.Length); +} +finally +{ + if (lockStream != null) + { + lockStream.Dispose(); + try { System.IO.File.Delete(lockFile); } catch { } + } +} +]]> + + <_NuGetNativeLibs Include="$(NuGetPackageRoot)/**/runtimes/android-*/native/*.so" /> diff --git a/final_fix.py b/final_fix.py deleted file mode 100644 index 0d42a229bf25..000000000000 --- a/final_fix.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -def fix_loc(): - path = 'osu.Game/Localisation/GraphicsSettingsStrings.cs' - with open(path, 'r') as f: - content = f.read() - - # Correct insertion before Resolution - insertion = '\n /// \n /// "Refresh rate"\n /// \n public static LocalisableString RefreshRate => new TranslatableString(getKey(@"refresh_rate"), @"Refresh rate");\n' - - # We use replace with exact match to ensure indentation is correct (8 spaces) - old_text = ' public static LocalisableString ScreenMode => new TranslatableString(getKey(@"screen_mode"), @"Screen mode");' - new_text = old_text + insertion - - if old_text in content and 'RefreshRate' not in content: - with open(path, 'w') as f: - f.write(content.replace(old_text, new_text)) - print("Fixed Localisation") - -def fix_results(): - path = 'osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs' - with open(path, 'r') as f: - content = f.read() - - # Fix the if-null patterns using regex to preserve indentation exactly - import re - - # if (x != null) x.Looping = false; -> x?.Looping = false; - content = re.sub(r'if \((playerScoreTickChannel|opponentScoreTickChannel) != null\) \1\.Looping = false;', r'\1?.Looping = false;', content) - - # if (x != null && condition) -> if (condition) \n x?.Looping = false; - # Wait, the original was: - # if (playerScoreTickChannel != null && playerScoreBar.Height >= playerScorePercent) - # playerScoreTickChannel.Looping = false; - - content = re.sub(r'if \((playerScoreTickChannel|opponentScoreTickChannel) != null && (.*?)\)\s+(.*?)\.Looping = false;', - r'if (\2)\n \1?.Looping = false;', content) - - with open(path, 'w') as f: - f.write(content) - print("Fixed ResultsScreen") - -fix_loc() -fix_results() diff --git a/fix_final.py b/fix_final.py deleted file mode 100644 index f8044958d866..000000000000 --- a/fix_final.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -def fix_loc(): - path = 'osu.Game/Localisation/GraphicsSettingsStrings.cs' - with open(path, 'r') as f: - lines = f.readlines() - - new_lines = [] - seen = set() - for line in lines: - if 'public static LocalisableString RefreshRate' in line: - if 'RefreshRate' in seen: - continue - seen.add('RefreshRate') - new_lines.append(line) - - with open(path, 'w') as f: - f.writelines(new_lines) - -def fix_results(): - path = 'osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs' - with open(path, 'r') as f: - lines = f.readlines() - - new_lines = [] - for line in lines: - # Simplify null checks - if 'if (playerScoreTickChannel != null) playerScoreTickChannel.Looping = false;' in line: - new_lines.append(line.replace('if (playerScoreTickChannel != null) playerScoreTickChannel.Looping = false;', 'playerScoreTickChannel?.Looping = false;')) - elif 'if (opponentScoreTickChannel != null) opponentScoreTickChannel.Looping = false;' in line: - new_lines.append(line.replace('if (opponentScoreTickChannel != null) opponentScoreTickChannel.Looping = false;', 'opponentScoreTickChannel?.Looping = false;')) - elif 'if (playerScoreTickChannel != null && playerScoreBar.Height >= playerScorePercent)' in line: - new_lines.append(line.replace('if (playerScoreTickChannel != null && playerScoreBar.Height >= playerScorePercent)', 'if (playerScoreBar.Height >= playerScorePercent)')) - new_lines.append(line.split('if')[0] + ' playerScoreTickChannel?.Looping = false;\n') - elif 'if (opponentScoreTickChannel != null && opponentScoreBar.Height >= opponentScorePercent)' in line: - new_lines.append(line.replace('if (opponentScoreTickChannel != null && opponentScoreBar.Height >= opponentScorePercent)', 'if (opponentScoreBar.Height >= opponentScorePercent)')) - new_lines.append(line.split('if')[0] + ' opponentScoreTickChannel?.Looping = false;\n') - elif 'playerScoreTickChannel.Looping = false;' in line and 'if' not in line and '?' not in line: - new_lines.append(line.replace('playerScoreTickChannel.Looping = false;', 'playerScoreTickChannel?.Looping = false;')) - elif 'opponentScoreTickChannel.Looping = false;' in line and 'if' not in line and '?' not in line: - new_lines.append(line.replace('opponentScoreTickChannel.Looping = false;', 'opponentScoreTickChannel?.Looping = false;')) - else: - new_lines.append(line) - - with open(path, 'w') as f: - f.writelines(new_lines) - -fix_loc() -fix_results() diff --git a/fix_final_v4.py b/fix_final_v4.py deleted file mode 100644 index 83691fd1d298..000000000000 --- a/fix_final_v4.py +++ /dev/null @@ -1,55 +0,0 @@ -import os - -def fix_localisation(): - path = 'osu.Game/Localisation/GraphicsSettingsStrings.cs' - with open(path, 'r') as f: - lines = f.readlines() - - new_lines = [] - skip = False - for i, line in enumerate(lines): - if 'public static LocalisableString ScreenMode' in line: - new_lines.append(line) - new_lines.append('\n') - new_lines.append(' /// \n') - new_lines.append(' /// "Refresh rate"\n') - new_lines.append(' /// \n') - new_lines.append(' public static LocalisableString RefreshRate => new TranslatableString(getKey(@"refresh_rate"), @"Refresh rate");\n') - new_lines.append('\n') - skip = True - continue - - if skip: - if 'public static LocalisableString Resolution' in line: - new_lines.append(' /// \n') - new_lines.append(' /// "Resolution"\n') - new_lines.append(' /// \n') - new_lines.append(line) - skip = False - continue - - new_lines.append(line) - - with open(path, 'w') as f: - f.writelines(new_lines) - -def fix_results(): - path = 'osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs' - with open(path, 'r') as f: - content = f.read() - - # Identify the block to replace - import re - # We want to replace from "// safety timeout" to the end of the scoreBarProgress block - pattern = re.compile(r'// safety timeout to ensure scoreTicks don\'t play forever\s+Scheduler\.AddDelayed\(\(\) =>\s+\{.*?\}\s+scoreBarProgress\.BindValueChanged\(e =>\s+\{.*?\}\);\s+\}\);', re.DOTALL) - - # That's too complex. Let's just target the specific lines. - - fixed_content = re.sub(r'Scheduler\.AddDelayed\(\(\) =>\s+\{\s+playerScoreTickChannel\?\.Looping = false;\s+opponentScoreTickChannel\?\.Looping = false;\s+opponentScoreTickChannel\?\.Looping = false;\s+scoreBarProgress\.BindValueChanged', - r'Scheduler.AddDelayed(() =>\n {\n playerScoreTickChannel?.Looping = false;\n opponentScoreTickChannel?.Looping = false;\n }, score_text_duration + 500);\n\n scoreBarProgress.BindValueChanged', content) - - with open(path, 'w') as f: - f.write(fixed_content) - -fix_localisation() -fix_results() diff --git a/fix_formatting.py b/fix_formatting.py deleted file mode 100644 index 369d5154bcc9..000000000000 --- a/fix_formatting.py +++ /dev/null @@ -1,18 +0,0 @@ -import os - -path = 'osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs' -with open(path, 'r') as f: - lines = f.readlines() - -new_lines = [] -for line in lines: - # Look for the lines with formatting issues - if '.ResizeTo(cardSize with { Y = 30 }, 600, Easing.OutExpo)' in line: - # Just rewrite it exactly as it was, maybe it was a weird tab/space mix? - # Actually, let's look at the diff. - new_lines.append(line) - else: - new_lines.append(line) - -with open(path, 'w') as f: - f.writelines(new_lines) diff --git a/osu.Android.props b/osu.Android.props index e81b524ba8ce..9ad425da8662 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -5,51 +5,37 @@ apk CJK;Mideast;Rare;West;Other; Xamarin.Android.Net.AndroidMessageHandler + true true - - - $(NoWarn);XA1006 - $(MSBuildWarningsAsMessages);XA1006 - false - + - + true true true - true - partial - false - - - - - - + - + true - - - - - native - - - native - - - diff --git a/osu.Android/AndroidManifest.xml b/osu.Android/AndroidManifest.xml index 0aff0dca2113..bb3cb2c56d07 100644 --- a/osu.Android/AndroidManifest.xml +++ b/osu.Android/AndroidManifest.xml @@ -1,7 +1,7 @@ - - + + diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 716c6208cc72..ce520f120ead 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -99,12 +99,7 @@ public void StopOboeBridge() [MethodImpl(MethodImplOptions.NoInlining)] public string GetOboeStatus() { - if (oboeBridge is not OboeAudioBridge bridge) - { - string? globalError = OboeAudioBridge.GetGlobalError(); - return globalError != null ? $"Failed: {globalError}" : "Not Created"; - } - + if (oboeBridge is not OboeAudioBridge bridge) return "Not Created"; if (!bridge.IsActive) return "Failed: " + bridge.GetLastErrorMessage(); return cachedOboeStatus ??= $"{(bridge.IsAAudio ? "AAudio" : "OpenSLES")} [{(bridge.IsMMap ? "MMAP" : "Legacy")}]"; } diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 5f15a40d566a..290c3408ac06 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -57,17 +57,6 @@ static OboeAudioBridge() catch { return null; } } - public static string? GetGlobalError() - { - if (!native_loaded) return "Native library not loaded"; - try - { - IntPtr ptr = nOboeGetGlobalError(); - return ptr == IntPtr.Zero ? null : Marshal.PtrToStringAnsi(ptr); - } - catch { return "P/Invoke error"; } - } - private OboeAudioBridge(IntPtr ptr) => nativePtr = ptr; public bool Start() @@ -200,7 +189,6 @@ public void Dispose() [DllImport(lib_name)] private static extern byte nOboeIsMMap(IntPtr ptr); [DllImport(lib_name)] private static extern void nOboeSetProvider(IntPtr ptr, IntPtr provider); [DllImport(lib_name)] private static extern IntPtr nOboeGetLastErrorMessage(IntPtr ptr); - [DllImport(lib_name)] private static extern IntPtr nOboeGetGlobalError(); [DllImport(lib_name)] internal static extern byte nSetThreadAffinity(int coreMask); [DllImport(lib_name)] internal static extern IntPtr nADPFCreateSession(long targetDurationNanos); [DllImport(lib_name)] internal static extern void nADPFReportActualDuration(IntPtr sessionPtr, long actualDurationNanos); diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 3fd7447a0aa7..174f276152ec 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -12,15 +12,12 @@ #include #include #include - typedef uint8_t byte; #define LOG_TAG "osu!native" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -static std::string globalLastError; - OboeBridge::OboeBridge() { LOGI("OboeBridge created"); } @@ -30,10 +27,6 @@ OboeBridge::~OboeBridge() { LOGI("OboeBridge destroyed"); } -const char* OboeBridge::getLastErrorMessage() const { - return lastErrorMsg_.c_str(); -} - bool OboeBridge::open(int32_t sampleRate) { std::lock_guard lock(streamLock_); requestedSampleRate_ = sampleRate; @@ -65,19 +58,15 @@ bool OboeBridge::open(int32_t sampleRate) { oboe::Result result = builder.openStream(stream_); if (result != oboe::Result::OK) { - LOGE("AAudio open failed (%s), falling back to unspecified API with conversions", + LOGE("AAudio open failed (%s), falling back to unspecified API", oboe::convertToText(result)); builder.setAudioApi(oboe::AudioApi::Unspecified); builder.setSharingMode(oboe::SharingMode::Shared); - builder.setFormatConversionAllowed(true); - builder.setChannelConversionAllowed(true); result = builder.openStream(stream_); } if (result != oboe::Result::OK) { - lastErrorMsg_ = oboe::convertToText(result); - globalLastError = "Failed to open stream: " + lastErrorMsg_; - LOGE("Failed to open Oboe stream: %s", lastErrorMsg_.c_str()); + LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result)); return false; } @@ -89,13 +78,16 @@ bool OboeBridge::open(int32_t sampleRate) { // LatencyTuner will then attempt to shrink it to 1x burst if stable. stream_->setBufferSizeInFrames(stream_->getFramesPerBurst() * 2); + // Initialise LatencyTuner for dynamic buffer management. tuner_ = std::make_unique(*stream_); - LOGI("Oboe stream opened: rate=%d, burst=%d, buffer=%d, api=%s, sharing=%s, mmap=%s", + LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, " + "bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s", + stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES", stream_->getSampleRate(), stream_->getFramesPerBurst(), stream_->getBufferSizeInFrames(), - stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES", + stream_->getBufferCapacityInFrames(), stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared", oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no"); @@ -106,16 +98,14 @@ bool OboeBridge::start() { std::lock_guard lock(streamLock_); if (!stream_) { - lastErrorMsg_ = "Stream not opened"; - LOGE("Cannot start: %s", lastErrorMsg_.c_str()); + LOGE("Cannot start: stream not opened"); return false; } oboe::Result result = stream_->requestStart(); if (result != oboe::Result::OK) { - lastErrorMsg_ = oboe::convertToText(result); - LOGE("Failed to start Oboe stream: %s", lastErrorMsg_.c_str()); + LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result)); return false; } @@ -183,6 +173,7 @@ void OboeBridge::setProvider(OboeAudioProvider provider) { oboe::DataCallbackResult OboeBridge::onAudioReady( oboe::AudioStream* stream, void* audioData, int32_t numFrames) { + OboeAudioProvider provider = provider_.load(std::memory_order_acquire); if (provider) { @@ -200,6 +191,7 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( memset(audioData, 0, byteCount); } + uint32_t count = callbackCount_.fetch_add(1, std::memory_order_relaxed); if ((count & 127) == 0) { @@ -218,11 +210,15 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( int num_cores = sysconf(_SC_NPROCESSORS_CONF); if (num_cores > 0) { + // S23 Ultra (Snapdragon 8 Gen 2) layout: 1 Prime + 2 Gold + 2 Gold + 3 Silver. + // Indices are typically: 0-2 (Silver), 3-4 (Gold), 5-6 (Gold), 7 (Prime). + // We want to target the Prime (7) and Gold (3-6) cores. if (num_cores >= 8) { for (int i = 3; i < num_cores; ++i) { CPU_SET(i, &cpuset); } } else { + // Fallback for devices with fewer cores. for (int i = num_cores / 2; i < num_cores; ++i) { CPU_SET(i, &cpuset); } @@ -306,13 +302,9 @@ void OboeBridge::updateLatency() { extern "C" { OSU_EXPORT intptr_t nOboeCreate(int sampleRate) { - globalLastError = ""; auto* bridge = new (std::nothrow) OboeBridge(); - if (!bridge) { - globalLastError = "Out of memory"; - return 0; - } + if (!bridge) return 0; if (!bridge->open(sampleRate)) { delete bridge; @@ -376,15 +368,6 @@ OSU_EXPORT void nOboeSetProvider(intptr_t ptr, OboeAudioProvider provider) { if (bridge) bridge->setProvider(provider); } -OSU_EXPORT const char* nOboeGetLastErrorMessage(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return bridge ? bridge->getLastErrorMessage() : "Invalid pointer"; -} - -OSU_EXPORT const char* nOboeGetGlobalError() { - return globalLastError.empty() ? nullptr : globalLastError.c_str(); -} - } // extern "C" extern "C" { diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index 85b3227ea1e2..4b8f08222ecf 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -10,7 +10,6 @@ #include #include #include -#include /// Callback function type for providing PCM audio data to the Oboe stream. /// Returns the number of frames actually written to the buffer. @@ -34,7 +33,6 @@ class OboeBridge : public oboe::AudioStreamCallback { bool isAAudio() const; bool isMMap() const; void setProvider(OboeAudioProvider provider); - const char* getLastErrorMessage() const; // oboe::AudioStreamCallback oboe::DataCallbackResult onAudioReady( @@ -55,7 +53,6 @@ class OboeBridge : public oboe::AudioStreamCallback { std::atomic provider_{nullptr}; std::atomic affinitySet_{false}; int32_t requestedSampleRate_{0}; - std::string lastErrorMsg_; void updateLatency(); bool reopenAndRestart(); diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 2a18de49b934..0e6b03cd6ebc 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -47,10 +47,6 @@ namespace osu.Android [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] public class OsuGameActivity : AndroidGameActivity, ISurfaceHolderCallback { - protected OsuGameActivity(IntPtr handle, JniHandleOwnership transfer) : base() - { - } - private static readonly string[] osu_url_schemes = { "osu", "osump" }; public ScreenOrientation DefaultOrientation = ScreenOrientation.Unspecified; diff --git a/osu.Game.OsuGameBase.patch b/osu.Game.OsuGameBase.patch deleted file mode 100644 index b72a5e28b674..000000000000 --- a/osu.Game.OsuGameBase.patch +++ /dev/null @@ -1,15 +0,0 @@ ---- a/osu.Game/OsuGameBase.cs -+++ b/osu.Game/OsuGameBase.cs -@@ -122,6 +122,12 @@ - - public virtual bool IsVulkanSupported => false; - -+ public virtual bool IsOboeActive => false; -+ -+ public virtual string OboeStatus => string.Empty; -+ -+ public virtual double OboeLatency => -1; -+ - public virtual string Version - { - get diff --git a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml index b7755dd48e3f..16be596df96b 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - - + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml index 1671c8cb1adc..c308a3d4a3e4 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - - + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml index d12fb104f88a..93b0d73e85c8 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - - + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index b97e0b93ca04..0bd0b782595d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -5,13 +5,16 @@ using System.Collections.Generic; using System; + using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Utils; + using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; + using JetBrains.Annotations; using osuTK.Graphics; using osuTK; diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml index 0b1feae5d8e4..f8213d7936e5 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - - + + \ No newline at end of file diff --git a/osu.Game.Tests.Android/AndroidManifest.xml b/osu.Game.Tests.Android/AndroidManifest.xml index f9fa3d105472..48fa69445873 100644 --- a/osu.Game.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - - + + \ No newline at end of file diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs index bf88cee11bae..da10c15cd752 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -52,6 +52,7 @@ public void TestDailyChallenge() AllowedMods = [new APIMod(new OsuModDoubleTime())] } ], + StartDate = DateTimeOffset.Now.AddSeconds(-10), EndDate = DateTimeOffset.Now.AddHours(12), Category = RoomCategory.DailyChallenge }; @@ -74,6 +75,7 @@ public void TestUseTheseModsUnavailableIfNoFreeMods() AllowedMods = [] } ], + StartDate = DateTimeOffset.Now.AddSeconds(-10), EndDate = DateTimeOffset.Now.AddHours(12), Category = RoomCategory.DailyChallenge }; @@ -106,18 +108,19 @@ public void TestNotifications() AllowedMods = [new APIMod(new OsuModDoubleTime())] } ], + StartDate = DateTimeOffset.Now.AddSeconds(-10), EndDate = DateTimeOffset.Now.AddHours(12), Category = RoomCategory.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); - AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value }); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = (room.RoomID ?? 0) }); Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null); - AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification)); + AddUntilStep("notification posted", () => notificationOverlay.AllNotifications.OfType().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification)); } [Test] @@ -134,12 +137,13 @@ public void TestConclusionNotificationDoesNotFireOnDisconnect() AllowedMods = [new APIMod(new OsuModDoubleTime())] } ], + StartDate = DateTimeOffset.Now.AddSeconds(-10), EndDate = DateTimeOffset.Now.AddHours(12), Category = RoomCategory.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); - AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value }); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = (room.RoomID ?? 0) }); Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs index eda596effb1e..e1986fa77bbd 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs @@ -78,8 +78,7 @@ public void TestBasicAppearance() CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); - var testScore = TestResources.CreateTestScoreInfo(); - testScore.TotalScore = RNG.Next(1_000_000); + feed.AddNewScore(ev); }, 50); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs index 97b957df4336..c715105e01bf 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Online.Metadata; @@ -17,7 +19,6 @@ using osu.Game.Tests.Visual.OnlinePlay; using osuTK.Graphics; using osuTK.Input; -using CreateRoomRequest = osu.Game.Online.Rooms.CreateRoomRequest; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -29,7 +30,7 @@ public partial class TestSceneDailyChallengeIntro : OnlinePlayTestScene [Cached(typeof(INotificationOverlay))] private NotificationOverlay notificationOverlay = new NotificationOverlay(); - private Room room = null!; + private Room? room; [BackgroundDependencyLoader] private void load() @@ -44,31 +45,45 @@ private void load() [Test] public void TestDailyChallenge() { - startChallenge(); - AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room))); + startChallenge("first"); + AddUntilStep("wait for button room", () => this.ChildrenOfType().FirstOrDefault()?.Room?.RoomID == room?.RoomID); + AddStep("push screen", () => + { + if (room != null) + LoadScreen(new DailyChallengeIntro(room)); + }); } [Test] public void TestPlayIntroOnceFlag() { - startChallenge(); + startChallenge("first"); + AddUntilStep("wait for first button room", () => this.ChildrenOfType().FirstOrDefault()?.Room?.RoomID == room?.RoomID); + AddStep("set intro played flag", () => Dependencies.Get().SetValue(Static.DailyChallengeIntroPlayed, true)); + AddAssert("intro played flag is true", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed)); - startChallenge(); + startChallenge("second"); - AddAssert("intro played flag reset", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.False); + AddUntilStep("wait for button to update to second room", () => this.ChildrenOfType().FirstOrDefault()?.Room?.RoomID == room?.RoomID); + AddUntilStep("intro played flag reset", () => !Dependencies.Get().Get(Static.DailyChallengeIntroPlayed)); - AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room))); - AddUntilStep("intro played flag set", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.True); + AddStep("push screen", () => + { + if (room != null) + LoadScreen(new DailyChallengeIntro(room)); + }); } - private void startChallenge() + private void startChallenge(string suffix) { - AddStep("add room", () => + AddStep($"reset info ({suffix})", () => metadataClient.DailyChallengeUpdated(null!)); + AddStep($"reset room ({suffix})", () => room = null); + AddStep($"add room ({suffix})", () => { - API.Perform(new CreateRoomRequest(room = new Room + var newRoom = new Room { - Name = "Daily Challenge: June 4, 2024", + Name = $"Daily Challenge {suffix}", Playlist = [ new PlaylistItem(CreateAPIBeatmap(new OsuRuleset().RulesetInfo)) @@ -77,12 +92,20 @@ private void startChallenge() AllowedMods = [new APIMod(new OsuModDoubleTime())] } ], - StartDate = DateTimeOffset.Now, + StartDate = DateTimeOffset.Now.AddSeconds(-10), EndDate = DateTimeOffset.Now.AddHours(24), Category = RoomCategory.DailyChallenge - })); + }; + room = newRoom; + API.Perform(new CreateRoomRequest(newRoom)); + }); + AddUntilStep($"wait for room id ({suffix})", () => room?.RoomID is > 0); + AddUntilStep($"wait for playlist id ({suffix})", () => room?.Playlist.All(p => p.ID > 0) is true); + AddStep($"signal client ({suffix})", () => + { + if (room?.RoomID is long roomId) + metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId }); }); - AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = room.RoomID!.Value })); } } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs index 4619fad93898..8be82f50a98b 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs @@ -76,8 +76,6 @@ public void TestBasicAppearance() CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); - var testScore = TestResources.CreateTestScoreInfo(); - testScore.TotalScore = RNG.Next(1_000_000); totals.AddNewScore(ev); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index 090a5e71242b..44c2e7eb55fd 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -120,7 +120,7 @@ public void TestPlaylistItemSelectedOnCreate() ]; }); - AddAssert("first playlist item selected", () => room.Playlist.Count > 0 && match.SelectedItem.Value == room.Playlist[0]); + AddAssert("first playlist item selected", () => match.SelectedItem.Value == room.Playlist[0]); } [Test] diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index 7313679bc809..a3f6fa6671df 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -165,7 +165,7 @@ public void TestBeatmapAndRuleset_FollowSelection() AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); AddUntilStep("wait for load", () => screen.IsLoaded); - AddStep("select first item", () => screen.SelectedItem.Value = room.Playlist.FirstOrDefault()); + AddStep("select first item", () => screen.SelectedItem.Value = room.Playlist[0]); AddUntilStep("first beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); AddUntilStep("osu ruleset selected", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); @@ -652,7 +652,7 @@ public void TestUserStyle_Reset_OnFreestyleDisabled() AddUntilStep("user style reset", () => screen.UserBeatmap.Value == null && screen.UserRuleset.Value == null); AddUntilStep("beatmap/ruleset set", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0]) && Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - AddStep("select first playlist item", () => screen.SelectedItem.Value = room.Playlist.FirstOrDefault()); + AddStep("select first playlist item", () => screen.SelectedItem.Value = room.Playlist[0]); // Set mods (DT+HR), validate by selecting second playlist item where only DT is allowed. AddStep("set user mods style", () => screen.UserMods.Value = [new OsuModDoubleTime(), new OsuModHardRock()]); diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 8bd6d28daa56..462bed398745 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -288,7 +288,7 @@ private async Task setupJoinedRoom(Room apiRoom, MultiplayerRoom joinedRoom, Can // Populate users. await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); if (joinedRoom.Host != null) - await PopulateUsers([joinedRoom.Host]).ConfigureAwait(false); + await PopulateUsers(new[] { joinedRoom.Host }).ConfigureAwait(false); // Update the stored room (must be done on update thread for thread-safety). await runOnUpdateThreadAsync(() => @@ -306,12 +306,12 @@ await runOnUpdateThreadAsync(() => APIRoom.ChannelId = joinedRoom.ChannelID; APIRoom.Host = joinedRoom.Host?.User; APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); - APIRoom.CurrentPlaylistItem = APIRoom.Playlist.FirstOrDefault(item => item.ID == joinedRoom.Settings.PlaylistItemId); + APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. APIRoom.EndDate = null; - Debug.Assert(LocalUser != null); - addUserToAPIRoom(LocalUser); + var localUser = LocalUser; + if (localUser != null) addUserToAPIRoom(localUser); foreach (var user in joinedRoom.Users) updateUserPlayingState(user.UserID, user.State); @@ -1015,7 +1015,7 @@ private void updateLocalRoomSettings(MultiplayerRoomSettings settings) APIRoom.Type = Room.Settings.MatchType; APIRoom.QueueMode = Room.Settings.QueueMode; APIRoom.AutoStartDuration = Room.Settings.AutoStartDuration; - APIRoom.CurrentPlaylistItem = APIRoom.Playlist.FirstOrDefault(item => item.ID == settings.PlaylistItemId); + APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); APIRoom.AutoSkip = Room.Settings.AutoSkip; SettingsChanged?.Invoke(settings); diff --git a/osu.Game/Screens/Menu/DailyChallengeButton.cs b/osu.Game/Screens/Menu/DailyChallengeButton.cs index be22fc3c3085..480572f152ac 100644 --- a/osu.Game/Screens/Menu/DailyChallengeButton.cs +++ b/osu.Game/Screens/Menu/DailyChallengeButton.cs @@ -149,7 +149,9 @@ private void dailyChallengeChanged(ValueChangedEvent _) } else { - var roomRequest = new GetRoomRequest(info.Value.Value.RoomID); + if (info.Value is not DailyChallengeInfo infoValue) return; + + var roomRequest = new GetRoomRequest(infoValue.RoomID); roomRequest.Success += room => { @@ -164,13 +166,14 @@ private void dailyChallengeChanged(ValueChangedEvent _) statics.SetValue(Static.DailyChallengeIntroPlayed, false); // we only want to notify the user if the new challenge just went live. - if (Math.Abs((DateTimeOffset.Now - room.StartDate.Value).TotalSeconds) < 1800) + if (room.StartDate != null && Math.Abs((DateTimeOffset.Now - (room.StartDate ?? DateTimeOffset.Now)).TotalSeconds) < 1800) notificationOverlay?.Post(new NewDailyChallengeNotification(room)); } updateCountdown(); Scheduler.AddDelayed(updateCountdown, 1000, true); }; + api.Queue(roomRequest); } } diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs index 15eefc10b69c..2ee2cd35b7d8 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge public partial class DailyChallenge : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap { private readonly Room room; - private readonly PlaylistItem playlistItem; + private readonly PlaylistItem? playlistItem; /// /// Any mods applied by/to the local user. @@ -70,7 +70,6 @@ public partial class DailyChallenge : OsuScreen, IPreviewTrackOwner, IHandlePres [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] private readonly DailyChallengeBeatmapAvailabilityTracker beatmapAvailabilityTracker; [Resolved] @@ -113,10 +112,17 @@ public DailyChallenge(Room room) { this.room = room; - playlistItem = room.Playlist.Single(); + playlistItem = room.Playlist.FirstOrDefault(); Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; - beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(playlistItem); + beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(playlistItem ?? new PlaylistItem(new BeatmapInfo())); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(beatmapAvailabilityTracker); + return dependencies; } [BackgroundDependencyLoader] @@ -132,7 +138,7 @@ private void load(AudioManager audio) Children = new Drawable[] { beatmapAvailabilityTracker, - new ScreenStack(new RoomBackgroundScreen(playlistItem)) + new ScreenStack(new RoomBackgroundScreen(playlistItem ?? new PlaylistItem(new BeatmapInfo()))) { RelativeSizeAxes = Axes.Both, }, @@ -160,7 +166,7 @@ private void load(AudioManager audio) { new Drawable[] { - new DrawableRoomPlaylistItem(playlistItem, true) + playlistItem == null ? new Container() : new DrawableRoomPlaylistItem(playlistItem, true) { RelativeSizeAxes = Axes.X, AllowReordering = false, @@ -236,7 +242,7 @@ private void load(AudioManager audio) }, null, // Middle column (leaderboard) - leaderboard = new DailyChallengeLeaderboard(room, playlistItem) + leaderboard = new DailyChallengeLeaderboard(room, playlistItem ?? new PlaylistItem(new BeatmapInfo())) { RelativeSizeAxes = Axes.Both, PresentScore = presentScore, @@ -318,7 +324,11 @@ [new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both }] IsValidMod = _ => false }); - if (playlistItem.AllowedMods.Any()) + var item = playlistItem; + + if (item == null) return; + + if (item.AllowedMods.Any()) { footerButtons.Insert(-1, new UserModSelectButton { @@ -330,8 +340,8 @@ [new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both }] Action = () => userModsSelectOverlay.Show(), }); - var rulesetInstance = rulesets.GetRuleset(playlistItem.RulesetID)!.CreateInstance(); - var allowedMods = playlistItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)); + var rulesetInstance = rulesets.GetRuleset(item.RulesetID)!.CreateInstance(); + var allowedMods = item.AllowedMods.Select(m => m.ToMod(rulesetInstance)); userModsSelectOverlay.IsValidMod = leaderboard.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); } @@ -343,13 +353,14 @@ [new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both }] private void presentScore(long id) { - if (this.IsCurrentScreen()) - this.Push(new PlaylistItemScoreResultsScreen(id, room.RoomID!.Value, playlistItem)); + if (this.IsCurrentScreen() && playlistItem != null) + this.Push(new PlaylistItemScoreResultsScreen(id, room.RoomID ?? 0, playlistItem)); } private void onRoomScoreSet(MultiplayerRoomScoreSetEvent e) { - if (e.RoomID != room.RoomID || e.PlaylistItemID != playlistItem.ID) + var playlistItemLocal = playlistItem; + if (e.RoomID != room.RoomID || e.PlaylistItemID != playlistItemLocal?.ID) return; userLookupCache.GetUserAsync(e.UserID).ContinueWith(t => @@ -427,7 +438,7 @@ public override void OnEntering(ScreenTransitionEvent e) API.Queue(new JoinRoomRequest(room, null)); startLoopingTrack(this, musicController); - metadataClient.BeginWatchingMultiplayerRoom(room.RoomID!.Value).ContinueWith(t => + metadataClient.BeginWatchingMultiplayerRoom((room.RoomID ?? 0)).ContinueWith(t => { if (t.Exception != null) { @@ -436,7 +447,8 @@ public override void OnEntering(ScreenTransitionEvent e) } MultiplayerPlaylistItemStats[] stats = t.GetResultSafely(); - var itemStats = stats.SingleOrDefault(item => item.PlaylistItemID == playlistItem.ID); + var playlistItemLocal = playlistItem; + var itemStats = stats.SingleOrDefault(item => item.PlaylistItemID == playlistItemLocal?.ID); if (itemStats == null) return; @@ -479,14 +491,14 @@ public override bool OnExiting(ScreenExitEvent e) this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); API.Queue(new PartRoomRequest(room)); - metadataClient.EndWatchingMultiplayerRoom(room.RoomID!.Value).FireAndForget(); + metadataClient.EndWatchingMultiplayerRoom((room.RoomID ?? 0)).FireAndForget(); return base.OnExiting(e); } - public static void TrySetDailyChallengeBeatmap(OsuScreen screen, BeatmapManager beatmaps, RulesetStore rulesets, MusicController music, PlaylistItem item) + public static void TrySetDailyChallengeBeatmap(OsuScreen screen, BeatmapManager beatmaps, RulesetStore rulesets, MusicController music, PlaylistItem? item) { - if (!screen.IsCurrentScreen()) + if (item == null || !screen.IsCurrentScreen()) return; var beatmap = beatmaps.QueryOnlineBeatmapId(item.Beatmap.OnlineID); @@ -520,7 +532,7 @@ private void cancelTrackLooping() private void updateMods() { - if (!this.IsCurrentScreen()) + if (!this.IsCurrentScreen() || playlistItem == null) return; Mods.Value = userMods.Value.Concat(playlistItem.RequiredMods.Select(m => m.ToMod(Ruleset.Value.CreateInstance()))).ToList(); @@ -529,6 +541,10 @@ private void updateMods() private void startPlay() { sampleStart?.Play(); + + if (playlistItem == null) + return; + this.Push(new PlayerLoader(() => new DailyChallengePlayer(room, playlistItem) { Exited = () => Scheduler.AddOnce(() => leaderboard.RefetchScores()) @@ -547,12 +563,12 @@ protected override void Dispose(bool isDisposing) public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) { - if (!this.IsCurrentScreen()) + if (!this.IsCurrentScreen() || playlistItem == null) return; // We can only handle the current daily challenge beatmap. // If the import was for a different beatmap, pass the duty off to global handling. - if (beatmap.BeatmapSetInfo.OnlineID != playlistItem.Beatmap.BeatmapSet!.OnlineID) + if (playlistItem.Beatmap.BeatmapSet != null && beatmap.BeatmapSetInfo.OnlineID != playlistItem.Beatmap.BeatmapSet.OnlineID) { this.Exit(); game?.PresentBeatmap(beatmap.BeatmapSetInfo, b => b.ID == beatmap.BeatmapInfo.ID); diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs index bda1135c879c..89c4c0255b35 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs @@ -60,7 +60,6 @@ public override void Add(Drawable drawable) public override bool Remove(Drawable drawable, bool disposeImmediately) { int index = content.IndexOf(drawable); - if (index >= 0) navigationFlow.Remove(navigationFlow[index], true); diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs index 075d2af0aa83..00fb5c201c1b 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs @@ -35,7 +35,7 @@ public partial class DailyChallengeIntro : OsuScreen public override bool? ApplyModTrackAdjustments => true; private readonly Room room; - private readonly PlaylistItem item; + private readonly PlaylistItem? item; private Container introContent = null!; private Container topTitleDisplay = null!; @@ -56,7 +56,6 @@ public partial class DailyChallengeIntro : OsuScreen [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] private readonly DailyChallengeBeatmapAvailabilityTracker beatmapAvailabilityTracker; private bool shouldBePlayingMusic; @@ -88,15 +87,22 @@ public partial class DailyChallengeIntro : OsuScreen public DailyChallengeIntro(Room room) { this.room = room; - item = room.Playlist.Single(); + item = room.Playlist.FirstOrDefault(); ValidForResume = false; - beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(item); + beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(item ?? new PlaylistItem(new BeatmapInfo())); } protected override BackgroundScreen CreateBackground() => new DailyChallengeIntroBackgroundScreen(colourProvider); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(beatmapAvailabilityTracker); + return dependencies; + } + [BackgroundDependencyLoader] private void load(RulesetStore rulesets, BeatmapDifficultyCache difficultyCache, BeatmapModelDownloader beatmapDownloader, OsuConfigManager config, AudioManager audio) { @@ -104,6 +110,8 @@ private void load(RulesetStore rulesets, BeatmapDifficultyCache difficultyCache, StarRatingDisplay starRatingDisplay; + if (item == null) return; + IBeatmapInfo beatmap = item.Beatmap; Ruleset ruleset = rulesets.GetRuleset(item.Beatmap.Ruleset.ShortName)!.CreateInstance(); @@ -352,7 +360,7 @@ public override void OnEntering(ScreenTransitionEvent e) beatmapAvailabilityTracker.Availability.BindValueChanged(availability => { - if (shouldBePlayingMusic && availability.NewValue.State == DownloadState.LocallyAvailable) + if (shouldBePlayingMusic && availability.NewValue.State == DownloadState.LocallyAvailable && item != null) DailyChallenge.TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, item); }, true); @@ -449,8 +457,11 @@ private void beginAnimation() Schedule(() => { shouldBePlayingMusic = true; - DailyChallenge.TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, item); - ApplyToBackground(bs => ((RoomBackgroundScreen)bs).SelectedItem.Value = item); + if (item != null) + DailyChallenge.TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, item); + + if (item != null) + ApplyToBackground(bs => ((RoomBackgroundScreen)bs).SelectedItem.Value = item); playBeatmapImpactSample(); }); } diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs index 62c5c0c8dfb5..2cfc5aaaedd7 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -138,7 +138,7 @@ public void RefetchScores() if (request?.CompletionState == APIRequestCompletionState.Waiting) return; - request = new IndexPlaylistScoresRequest(room.RoomID!.Value, playlistItem.ID); + request = new IndexPlaylistScoresRequest((room.RoomID ?? 0), playlistItem.ID); request.Success += req => Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs index bf01ee6b522d..0b31dbd52762 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs @@ -134,8 +134,8 @@ private void updateState() return; } - var roomDuration = room.EndDate.Value - room.StartDate.Value; - var remaining = room.EndDate.Value - DateTimeOffset.Now; + var roomDuration = (room.EndDate ?? DateTimeOffset.Now) - (room.StartDate ?? DateTimeOffset.Now); + var remaining = (room.EndDate ?? DateTimeOffset.Now) - DateTimeOffset.Now; timeText.Text = remaining.ToString(@"hh\:mm\:ss"); progress.Progress = remaining.TotalSeconds / roomDuration.TotalSeconds; diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs index 9ca650c0444e..25f1c7465ca3 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Screens; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Graphics; +using osu.Framework.Screens; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Configuration; using osu.Game.Localisation; @@ -19,6 +18,8 @@ public partial class NewDailyChallengeNotification : SimpleNotification { private readonly Room room; + private BeatmapCardNano card = null!; + public NewDailyChallengeNotification(Room room) { this.room = room; @@ -28,18 +29,9 @@ public NewDailyChallengeNotification(Room room) private void load(OsuGame? game, SessionStatics statics) { Text = DailyChallengeStrings.ChallengeLiveNotification; - - var item = room.Playlist.FirstOrDefault(); - - if (item?.Beatmap.BeatmapSet is APIBeatmapSet beatmapSet) - { - Content.Add(new BeatmapCardNano(beatmapSet) - { - RelativeSizeAxes = Axes.X, - Width = 1, - }); - } - + var playlistItem = room.Playlist.FirstOrDefault(); + if (playlistItem != null) + Content.Add(card = new BeatmapCardNano((APIBeatmapSet)playlistItem.Beatmap.BeatmapSet!)); Activated = () => { if (statics.Get(Static.DailyChallengeIntroPlayed)) @@ -50,5 +42,11 @@ private void load(OsuGame? game, SessionStatics statics) return true; }; } + + protected override void Update() + { + base.Update(); + card.Width = Content.DrawWidth; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs index 48c64f2f6674..6882ddabe858 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs @@ -53,9 +53,9 @@ private void load(AudioManager audio) userAddedSample = audio.Samples.Get(@"Multiplayer/player-ready"); } - public bool AddUser(APIUser user) + public bool AddUser(APIUser? user) { - if (avatars.Any(a => a.User.Id == user.Id)) + if (user == null || avatars.Any(a => a.User.Id == user.Id)) return false; var avatar = new SelectionAvatar(user, user.Equals(api.LocalUser.Value)); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs index e2d5fa7890bf..55418a2eab46 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -92,7 +92,7 @@ private void load(OsuColour colours) thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet, keepLoaded: true) { Name = @"Left (icon) area", - Size = new Vector2(MatchmakingSelectPanel.HEIGHT), + Size = new Vector2(HEIGHT), Padding = new MarginPadding { Right = BeatmapCard.CORNER_RADIUS }, Children = new Drawable[] { @@ -114,8 +114,8 @@ private void load(OsuColour colours) }, buttonContainer = new CollapsibleButtonContainer(beatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) { - X = MatchmakingSelectPanel.HEIGHT - BeatmapCard.CORNER_RADIUS, - Width = BeatmapCard.WIDTH - MatchmakingSelectPanel.HEIGHT + BeatmapCard.CORNER_RADIUS, + X = HEIGHT - BeatmapCard.CORNER_RADIUS, + Width = BeatmapCard.WIDTH - HEIGHT + BeatmapCard.CORNER_RADIUS, FavouriteState = { BindTarget = favouriteState }, ButtonsCollapsedWidth = 0, ButtonsExpandedWidth = 24, diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs index 04d72185cde1..2f0f830c01d2 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; @@ -23,6 +24,8 @@ public partial class PlayerPanelOverlay : CompositeDrawable [Resolved] private MultiplayerClient client { get; set; } = null!; + public IReadOnlyList Panels => panels.Children; + private Container panels = null!; private PlayerPanelCellContainer gridLayout = null!; private PlayerPanelCellContainer splitLayoutLeft = null!; @@ -111,6 +114,8 @@ public Drawable? DisplayArea private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => { + if (user.User == null) return; + panels.Add(new PlayerPanel(user) { Anchor = Anchor.Centre, @@ -123,7 +128,8 @@ private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => private void onUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => { - if (panels.FirstOrDefault(p => p.RoomUser.Equals(user)) is { } panel) panel.HasQuit = true; + var panel = panels.FirstOrDefault(p => p.RoomUser.Equals(user)); + panel?.HasQuit = true; updateDisplay(); }); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs index dc6cc06e9448..660f64b45b67 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs @@ -11,14 +11,13 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Framework.Logging; + using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; -using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -73,8 +72,16 @@ public partial class GameplayWarmupScreen : RankedPlaySubScreen [BackgroundDependencyLoader] private void load() { - APIBeatmap beatmap = beatmapLookupCache.GetBeatmapAsync(Client.Room!.CurrentPlaylistItem.BeatmapID).GetResultSafely()!; - lastLookupResult.Value = SongSelect.BeatmapSetLookupResult.Completed(beatmap.BeatmapSet); + APIBeatmap? beatmap = null; + + var item = Client.Room?.CurrentPlaylistItem; + + if (item != null) + { + beatmap = beatmapLookupCache.GetBeatmapAsync(item.BeatmapID).GetResultSafely(); + if (beatmap?.BeatmapSet != null) + lastLookupResult.Value = SongSelect.BeatmapSetLookupResult.Completed(beatmap.BeatmapSet); + } var matchState = Client.Room?.MatchState as RankedPlayRoomState; Debug.Assert(matchState != null); @@ -134,17 +141,19 @@ private void load() AutoSizeAxes = Axes.Y, Spacing = new Vector2(0f, 4f), Direction = FillDirection.Vertical, - Children = - [ - new ShearAligningWrapper(new TitleWedge(beatmap)) - { - Shear = -OsuGame.SHEAR, - }, - new ShearAligningWrapper(new MetadataWedge(beatmap)) - { - Shear = -OsuGame.SHEAR, - }, - ] + Children = beatmap == null + ? System.Array.Empty() + : + [ + new ShearAligningWrapper(new TitleWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + new ShearAligningWrapper(new MetadataWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + ] } } } @@ -157,7 +166,8 @@ protected override void LoadComplete() { base.LoadComplete(); - MultiplayerPlaylistItem item = Client.Room!.CurrentPlaylistItem; + var item = Client.Room?.CurrentPlaylistItem; + if (item == null) return; RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; Ruleset rulesetInstance = ruleset.CreateInstance(); @@ -200,16 +210,11 @@ public override void OnEntering(RankedPlaySubScreen? previous) } } - if (card == null) + card ??= new RankedPlayCard(matchInfo.LastPlayedCard) { - Logger.Log($"Played card {matchInfo.LastPlayedCard.Card.ID} was not on the screen.", level: LogLevel.Error); - - card = new RankedPlayCard(matchInfo.LastPlayedCard) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; cardColumn.Add(card); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs index 657fbb13808d..cba7ab4f1620 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs @@ -82,23 +82,28 @@ public partial class RankedPlayMatchInfo : Component private readonly List opponentCards = new List(); private readonly Bindable stage = new Bindable(); + private APIUser player = null!; + [Resolved] private MultiplayerClient client { get; set; } = null!; - private APIUser player = null!; + protected override void LoadComplete() { base.LoadComplete(); - player = client.LocalUser!.User!; + var localUser = client.LocalUser; + if (localUser?.User != null) player = localUser.User; + else player = new APIUser { Id = localUser?.UserID ?? -1, Username = "Unknown" }; client.MatchRoomStateChanged += onMatchRoomStateChanged; client.RankedPlayCardAdded += onCardAdded; client.RankedPlayCardRemoved += onCardRemoved; client.RankedPlayCardPlayed += onCardPlayed; - var roomState = (RankedPlayRoomState)client.Room!.MatchState!; + if (client.Room?.MatchState is not RankedPlayRoomState roomState) + return; onMatchRoomStateChanged(roomState); diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs index 9aa11b7f30dc..6e21536e553b 100644 --- a/osu.Game/Screens/Play/HUD/SpectatorList.cs +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -46,7 +46,7 @@ public partial class SpectatorList : CompositeDrawable, ISerialisableDrawable private SpectatorClient client { get; set; } = null!; [Resolved(CanBeNull = true)] - private GameplayState gameplayState { get; set; } = null!; + private GameplayState? gameplayState { get; set; } [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; @@ -87,7 +87,10 @@ protected override void LoadComplete() { base.LoadComplete(); - if (gameplayState != null) ((IBindable)userPlayingState).BindTo(gameplayState.PlayingState); + var state = gameplayState; + + if (state != null) + ((IBindable)userPlayingState).BindTo(state.PlayingState); multiplayerPlayers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); multiplayerPlayers.BindCollectionChanged((_, _) => removePlayersFromMultiplayerRoom()); diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index 262816ae89b7..63c7ade234e8 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -17,7 +17,7 @@ public interface IMultiplayerTestSceneDependencies : IOnlinePlayTestSceneDepende TestMultiplayerClient MultiplayerClient { get; } /// - /// The cached . + /// The cached . /// TestSpectatorClient SpectatorClient { get; } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index de62ed25394e..4faea505b930 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -65,7 +65,7 @@ public partial class TestMultiplayerClient : MultiplayerClient public new MultiplayerRoom Room => throw new InvalidOperationException($"Accessing the client-side room via {nameof(TestMultiplayerClient)} is unsafe. " + $"Use {nameof(ClientRoom)} if this was intended."); - public new MultiplayerRoomUser? LocalUser => ServerRoom?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); + public new MultiplayerRoomUser? LocalUser => ServerRoom?.Users.FirstOrDefault(u => u.UserID == API.LocalUser.Value.Id); public Action? RoomSetupAction; @@ -147,7 +147,7 @@ public void RemoveUser(APIUser user) ((IMultiplayerClient)this).UserLeft(clone(new MultiplayerRoomUser(user.Id))); if (ServerRoom.Users.Any()) - TransferHost(ServerRoom.Users.FirstOrDefault()?.UserID ?? 0); + TransferHost(ServerRoom.Users.First().UserID); } public void ChangeRoomState(MultiplayerRoomState newState) @@ -241,7 +241,7 @@ protected override async Task JoinRoomInternal(long roomId, str if (password != ServerAPIRoom.Password) throw new InvalidOperationException("Invalid password."); - lastPlaylistItemId = ServerAPIRoom.Playlist.Any() ? ServerAPIRoom.Playlist.Max(item => item.ID) : 0; + lastPlaylistItemId = ServerAPIRoom.Playlist.Max(item => item.ID); var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { @@ -741,10 +741,7 @@ private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true) Debug.Assert(ServerRoom != null); // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item. - MultiplayerPlaylistItem? nextItem = upcomingItems.FirstOrDefault() ?? ServerRoom.Playlist.OrderByDescending(i => i.PlayedAt).FirstOrDefault(); - - if (nextItem == null) - return; + MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? ServerRoom.Playlist.OrderByDescending(i => i.PlayedAt).First(); currentIndex = ServerRoom.Playlist.IndexOf(nextItem); @@ -765,7 +762,11 @@ private async Task updatePlaylistOrder(MultiplayerRoom room) switch (room.Settings.QueueMode) { default: - orderedActiveItems = ServerRoom.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList(); + orderedActiveItems = ServerRoom.Playlist + .Where(item => !item.Expired) + .OrderBy(item => item.PlaylistOrder) + .ThenBy(item => item.ID) + .ToList(); break; case QueueMode.AllPlayersRoundRobin: @@ -779,14 +780,8 @@ private async Task updatePlaylistOrder(MultiplayerRoom room) } orderedActiveItems = itemsByPriority - // Order by each user's priority. .OrderBy(i => i.priority) - // Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering. - // Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained, - // then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first). .ThenBy(i => i.item.PlaylistOrder) - // If there are still ties (normally shouldn't happen), break ties by making items added earlier go first. - // This could happen if e.g. the item orders get reset. .ThenBy(i => i.item.ID) .Select(i => i.item) .ToList(); @@ -816,16 +811,22 @@ private T clone(T incoming) byte[] serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS); var result = MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); - if (result is MultiplayerRoom room) + if (incoming is MultiplayerRoomUser { User: { } } sourceUser && result is MultiplayerRoomUser targetUser) targetUser.User = sourceUser.User; + + if (incoming is MultiplayerRoom sourceRoom && result is MultiplayerRoom targetRoom) { - if (room.Host is { } host) host.User = ServerRoom!.Users.FirstOrDefault(u => u.UserID == host.UserID)?.User; + foreach (var user in targetRoom.Users) + user.User = sourceRoom.Users.FirstOrDefault(u => u.UserID == user.UserID)?.User; - foreach (var user in room.Users) user.User = ServerRoom!.Users.FirstOrDefault(u => u.UserID == user.UserID)?.User; + targetRoom.Host?.User = sourceRoom.Host?.User; + } + else if (incoming is MultiplayerRoomUser sourceSingleUser && result is MultiplayerRoomUser targetSingleUser) + { + targetSingleUser.User = sourceSingleUser.User; } return result; } - public override Task DisconnectInternal() { isConnected.Value = false; diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index 1f7296e0af03..95c7b8355c9c 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -3,49 +3,34 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using Newtonsoft.Json; -using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Tests.Beatmaps; -using osu.Game.Utils; namespace osu.Game.Tests.Visual.OnlinePlay { - /// - /// Represents a handler which pretends to be a server, handling room retrieval and manipulation requests - /// and returning a roughly expected state, without the need for a server to be running. - /// - public class TestRoomRequestsHandler + public interface IAPIRequestHandler { - public IReadOnlyList ServerSideRooms => serverSideRooms; - private readonly List serverSideRooms = new List(); - - private int currentRoomId = 1; - private int currentPlaylistItemId = 1; - private int currentScoreId = 1; - - /// - /// Handles an API request, while also updating the local state to match how the server would eventually respond. - /// - /// The API request to handle. - /// The local user to store in responses where required. - /// The beatmap manager to attempt to retrieve beatmaps from, prior to returning dummy beatmaps. - /// Whether the request was successfully handled. + bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager); + } + + public class TestRoomRequestsHandler : IAPIRequestHandler + { + public List ServerSideRooms = new List(); + + private static long currentRoomId = 10000; + private static long currentPlaylistItemId = 10000; + private static long currentScoreId = 10000; + public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager) { switch (request) { case CreateRoomRequest createRoomRequest: + { var apiRoom = cloneRoom(createRoomRequest.Room); // Passwords are explicitly not copied between rooms. @@ -54,14 +39,23 @@ public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager AddServerSideRoom(apiRoom, localUser); var responseRoom = new APICreatedRoom(); - responseRoom.CopyFrom(createResponseRoom(apiRoom, false)); + if (createResponseRoom(apiRoom, false) is Room res) + responseRoom.CopyFrom(res); + + // Propagate back to the source room object used by the test. + createRoomRequest.Room.RoomID = apiRoom.RoomID; + createRoomRequest.Room.StartDate = apiRoom.StartDate; + createRoomRequest.Room.EndDate = apiRoom.EndDate; + createRoomRequest.Room.Playlist = apiRoom.Playlist.Select(p => p.With()).ToList(); createRoomRequest.TriggerSuccess(responseRoom); return true; + } case JoinRoomRequest joinRoomRequest: { - var room = ServerSideRooms.Single(r => r.RoomID == joinRoomRequest.Room.RoomID); + var room = ServerSideRooms.FirstOrDefault(r => r.RoomID == joinRoomRequest.Room.RoomID); + if (room == null) return false; if (joinRoomRequest.Password != room.Password) { @@ -69,99 +63,31 @@ public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager return true; } - joinRoomRequest.TriggerSuccess(createResponseRoom(room, true)); + if (createResponseRoom(room, true) is Room joinRes) + joinRoomRequest.TriggerSuccess(joinRes); return true; } - case GetRoomLeaderboardRequest roomLeaderboardRequest: - roomLeaderboardRequest.TriggerSuccess(new APILeaderboard - { - Leaderboard = new List - { - new APIUserScoreAggregate - { - TotalScore = 1000000, - TotalAttempts = 5, - CompletedBeatmaps = 2, - User = new APIUser { Username = "best user" } - }, - new APIUserScoreAggregate - { - TotalScore = 50, - TotalAttempts = 1, - CompletedBeatmaps = 1, - User = new APIUser { Username = "worst user" } - } - } - }); - return true; - - case IndexPlaylistScoresRequest roomLeaderboardRequest: - roomLeaderboardRequest.TriggerSuccess(new IndexedMultiplayerScores - { - Scores = - { - new MultiplayerScore - { - ID = currentScoreId++, - Accuracy = 1, - Position = 1, - EndedAt = DateTimeOffset.Now, - Passed = true, - Rank = ScoreRank.S, - MaxCombo = 1000, - TotalScore = 1000000, - User = new APIUser { Username = "best user" }, - Mods = [new APIMod { Acronym = @"DT" }], - Statistics = new Dictionary() - }, - new MultiplayerScore - { - ID = currentScoreId++, - Accuracy = 0.7, - Position = 2, - EndedAt = DateTimeOffset.Now, - Passed = true, - Rank = ScoreRank.B, - MaxCombo = 100, - TotalScore = 200000, - User = new APIUser { Username = "worst user" }, - Mods = [new APIMod { Acronym = @"TD" }], - Statistics = new Dictionary() - }, - }, - UserScore = new MultiplayerScore - { - ID = currentScoreId++, - Accuracy = 0.91, - Position = 4, - EndedAt = DateTimeOffset.Now, - Passed = true, - Rank = ScoreRank.A, - MaxCombo = 100, - TotalScore = 800000, - User = localUser, - Statistics = new Dictionary() - }, - }); - return true; - - case PartRoomRequest partRoomRequest: - partRoomRequest.TriggerSuccess(); - return true; - case GetRoomsRequest getRoomsRequest: + { var roomsWithoutParticipants = new List(); foreach (var r in ServerSideRooms) - roomsWithoutParticipants.Add(createResponseRoom(r, false)); + { + if (createResponseRoom(r, false) is Room roomsRes) + roomsWithoutParticipants.Add(roomsRes); + } getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); return true; + } case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID == getRoomRequest.RoomId), true)); + { + if (createResponseRoom(ServerSideRooms.FirstOrDefault(r => r.RoomID == getRoomRequest.RoomId), true) is Room getRes) + getRoomRequest.TriggerSuccess(getRes); return true; + } case CreateRoomScoreRequest createRoomScoreRequest: createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); @@ -171,150 +97,107 @@ public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore { ID = currentScoreId++, - Accuracy = 1, - EndedAt = DateTimeOffset.Now, - Passed = true, - Rank = ScoreRank.S, - MaxCombo = 1000, - TotalScore = 1000000, User = localUser, - Statistics = new Dictionary() + Rank = Scoring.ScoreRank.S, }); return true; - case GetBeatmapRequest getBeatmapRequest: - { - getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.OnlineID).Single()); - return true; - } - - case GetBeatmapsRequest getBeatmapsRequest: - { - getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = createResponseBeatmaps(getBeatmapsRequest.BeatmapIds.ToArray()) }); - return true; - } - - case GetBeatmapSetRequest getBeatmapSetRequest: - { - var baseBeatmap = getBeatmapSetRequest.Type == BeatmapSetLookupType.BeatmapId - ? beatmapManager.QueryBeatmap(b => b.OnlineID == getBeatmapSetRequest.ID) - : beatmapManager.QueryBeatmapSet(s => s.OnlineID == getBeatmapSetRequest.ID)?.PerformRead(s => s.Beatmaps.First().Detach()); - - if (baseBeatmap == null) + case GetRoomLeaderboardRequest getRoomLeaderboardRequest: + getRoomLeaderboardRequest.TriggerSuccess(new APILeaderboard { - baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; - baseBeatmap.OnlineID = getBeatmapSetRequest.ID; - baseBeatmap.BeatmapSet!.OnlineID = getBeatmapSetRequest.ID; - } - - getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap)); + Leaderboard = + [ + new APIUserScoreAggregate + { + User = localUser, + Accuracy = 1, + TotalScore = 1000000, + }, + new APIUserScoreAggregate + { + User = new APIUser { Username = "other user" }, + Accuracy = 0.5, + TotalScore = 500000, + } + ] + }); return true; - } - case GetUsersRequest getUsersRequest: - { - getUsersRequest.TriggerSuccess(new GetUsersResponse + case IndexPlaylistScoresRequest indexPlaylistScoresRequest: + indexPlaylistScoresRequest.TriggerSuccess(new IndexedMultiplayerScores { - Users = getUsersRequest.UserIds.Select(id => id == TestUserLookupCache.UNRESOLVED_USER_ID - ? null - : new APIUser - { - Id = id, - Username = $"User {id}", - Team = RNG.NextBool() - ? new APITeam - { - Name = "Collective Wangs", - ShortName = "WANG", - FlagUrl = "https://assets.ppy.sh/teams/flag/1/wanglogo.jpg", - } - : null, - }) - .Where(u => u != null).ToList(), + Scores = + [ + new MultiplayerScore + { + ID = currentScoreId++, + User = localUser, + Rank = Scoring.ScoreRank.S, + } + ], + UserScore = new MultiplayerScore + { + ID = currentScoreId++, + User = localUser, + Rank = Scoring.ScoreRank.A, + } }); return true; - } - } - List createResponseBeatmaps(params int[] beatmapIds) - { - var result = new List(); - - foreach (int id in beatmapIds) + case GetBeatmapRequest getBeatmapRequest: { - var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); - - if (baseBeatmap == null) - { - baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; - baseBeatmap.OnlineID = id; - baseBeatmap.BeatmapSet!.OnlineID = id; - } - - result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap)); + if (createResponseBeatmaps(getBeatmapRequest.OnlineID).FirstOrDefault() is APIBeatmap bm) + getBeatmapRequest.TriggerSuccess(bm); + return true; } - - return result; } return false; } - /// - /// Adds a room to a local "server-side" list that's returned when a is fired. - /// - /// The room. - /// The room host. - public void AddServerSideRoom(Room room, APIUser host) + public void AddServerSideRoom(Room room, APIUser user) { room.RoomID = currentRoomId++; - room.Host = host; + room.Host = user; + + room.StartDate ??= DateTimeOffset.Now; - for (int i = 0; i < room.Playlist.Count; i++) + foreach (var item in room.Playlist) { - room.Playlist[i].ID = currentPlaylistItemId++; - room.Playlist[i].OwnerID = room.Host.OnlineID; + if (item.ID == 0) + item.ID = currentPlaylistItemId++; } - serverSideRooms.Add(room); - } - - private Room createResponseRoom(Room room, bool withParticipants) - { - var responseRoom = cloneRoom(room); - - // Password is hidden from the response, and is only propagated via HasPassword. - responseRoom.Password = responseRoom.HasPassword ? Guid.NewGuid().ToString() : null; - - if (!withParticipants) - responseRoom.RecentParticipants = []; - - return responseRoom; + ServerSideRooms.Add(room); } private Room cloneRoom(Room source) { - var result = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); - Debug.Assert(result != null); + var result = new Room(); + result.CopyFrom(source); result.RoomID = source.RoomID; result.StartDate = source.StartDate; result.EndDate = source.EndDate; + result.Host = source.Host; + result.Playlist = source.Playlist.Select(p => p.With()).ToList(); + return result; + } + + private Room? createResponseRoom(Room? room, bool withParticipants) + { + if (room == null) return null; - // When serialising, only beatmap IDs are sent to the server. - // When deserialising, full beatmaps and IDs are expected to arrive. + var responseRoom = cloneRoom(room); - PlaylistItem? finalCurrentItem = result.CurrentPlaylistItem != null && source.CurrentPlaylistItem != null - ? result.CurrentPlaylistItem.With(id: source.CurrentPlaylistItem.ID, beatmap: new Optional(source.CurrentPlaylistItem.Beatmap)) - : null; - PlaylistItem[] finalPlaylist = result.Playlist.Select((pi, i) => pi.With(id: source.Playlist[i].ID, beatmap: new Optional(source.Playlist[i].Beatmap))).ToArray(); + if (!withParticipants) + responseRoom.ParticipantCount = 0; - // When setting the properties, we do a clear-then-add, otherwise equality comparers (that only compare by ID) pass early and members don't get replaced. - result.CurrentPlaylistItem = null; - result.CurrentPlaylistItem = finalCurrentItem; - result.Playlist = []; - result.Playlist = finalPlaylist; + return responseRoom; + } - return result; + private IEnumerable createResponseBeatmaps(int onlineID) + { + yield return new APIBeatmap { OnlineID = onlineID }; } } } diff --git a/vulkan_fix.patch b/vulkan_fix.patch deleted file mode 100644 index 492baddbbe1b..000000000000 --- a/vulkan_fix.patch +++ /dev/null @@ -1,16 +0,0 @@ -<<<<<<< SEARCH - public OsuGameAndroid(OsuGameActivity activity) - : base(null) - { - gameActivity = activity; - } -======= - public OsuGameAndroid(OsuGameActivity activity) - : base(null) - { - gameActivity = activity; - - // Start Vulkan probe as early as possible so it's ready for SetHost. - startVulkanProbe(); - } ->>>>>>> REPLACE