Skip to content

Commit 4d60d00

Browse files
authored
Merge pull request #216 from winnerspiros/copilot/fix-osu-android-crash-again
perf: update framework submodule + optimize gameplay hot paths
2 parents 14c0471 + 51f7b00 commit 4d60d00

11 files changed

Lines changed: 168 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ jobs:
2424
with:
2525
dotnet-version: "10.0.x"
2626

27+
- name: Cache NuGet packages
28+
uses: actions/cache@v5
29+
with:
30+
path: ~/.nuget/packages
31+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
32+
restore-keys: ${{ runner.os }}-nuget-
33+
2734
- name: Restore Tools
2835
run: dotnet tool restore
2936

@@ -94,6 +101,13 @@ jobs:
94101
with:
95102
dotnet-version: "10.0.x"
96103

104+
- name: Cache NuGet packages
105+
uses: actions/cache@v5
106+
with:
107+
path: ~/.nuget/packages
108+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
109+
restore-keys: ${{ runner.os }}-nuget-
110+
97111
- name: Compile
98112
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
99113

@@ -171,6 +185,13 @@ jobs:
171185
with:
172186
dotnet-version: "10.0.x"
173187

188+
- name: Cache NuGet packages
189+
uses: actions/cache@v5
190+
with:
191+
path: ~/.nuget/packages
192+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
193+
restore-keys: ${{ runner.os }}-nuget-
194+
174195
- name: Install .NET workloads
175196
run: dotnet workload install android
176197

@@ -192,6 +213,14 @@ jobs:
192213
uses: actions/setup-dotnet@v5
193214
with:
194215
dotnet-version: "10.0.x"
216+
217+
- name: Cache NuGet packages
218+
uses: actions/cache@v5
219+
with:
220+
path: ~/.nuget/packages
221+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
222+
restore-keys: ${{ runner.os }}-nuget-
223+
195224
- name: Set Xcode version
196225
run: sudo xcode-select -s /Applications/Xcode_26.3.app
197226

osu.Game/Online/OnlineStatusNotifier.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ protected override void Dispose(bool isDisposing)
161161
{
162162
base.Dispose(isDisposing);
163163

164+
apiState.UnbindAll();
165+
multiplayerState.UnbindAll();
166+
spectatorState.UnbindAll();
167+
164168
if (notificationsClient.IsNotNull())
165169
notificationsClient.MessageReceived -= notifyAboutForcedDisconnection;
166170

osu.Game/Rulesets/Scoring/HitEventExtensions.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,23 @@ public static class HitEventExtensions
6363
/// </returns>
6464
public static double? CalculateAverageHitError(this IEnumerable<HitEvent> hitEvents)
6565
{
66-
double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray();
66+
// Single-pass mean using running sum — avoids allocating a temporary array.
67+
double sum = 0;
68+
int count = 0;
6769

68-
if (timeOffsets.Length == 0)
70+
foreach (var ev in hitEvents)
71+
{
72+
if (!AffectsUnstableRate(ev))
73+
continue;
74+
75+
sum += ev.TimeOffset;
76+
count++;
77+
}
78+
79+
if (count == 0)
6980
return null;
7081

71-
return timeOffsets.Average();
82+
return sum / count;
7283
}
7384

7485
/// <summary>

osu.Game/Rulesets/UI/DrawableRuleset.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ public bool RemoveHitObject(TObject hitObject)
271271
return true;
272272

273273
// If the entry was not removed from the playfield, assume the hitobject is not being pooled and attempt a direct drawable removal.
274-
var drawableObject = Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == hitObject);
274+
var drawableObject = Playfield.AllHitObjects.FirstOrDefault(d => d.HitObject == hitObject);
275275
if (drawableObject != null)
276276
return Playfield.Remove(drawableObject);
277277

osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,8 @@ public virtual void Play()
6464
if (nextObject == null)
6565
return;
6666

67-
var samples = nextObject.Samples
68-
.Cast<ISampleInfo>()
69-
.ToArray();
67+
// HitSampleInfo implements ISampleInfo, so array covariance lets us skip .Cast<>().
68+
var samples = nextObject.Samples.ToArray();
7069

7170
PlaySamples(samples);
7271
}
@@ -103,10 +102,25 @@ protected override void Update()
103102
{
104103
// We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty).
105104
// If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
106-
var candidate =
107-
// Use alive entries first as an optimisation.
108-
hitObjectContainer.AliveEntries.Keys.Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime)
109-
?? hitObjectContainer.Entries.Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime);
105+
106+
// Use alive entries first as an optimisation (single-pass minimum, no LINQ allocation).
107+
HitObjectLifetimeEntry? candidate = null;
108+
109+
foreach (var e in hitObjectContainer.AliveEntries.Keys)
110+
{
111+
if (!isAlreadyHit(e) && (candidate == null || e.HitObject.StartTime < candidate.HitObject.StartTime))
112+
candidate = e;
113+
}
114+
115+
// Fall back to full entries if no alive non-judged entry found.
116+
if (candidate == null)
117+
{
118+
foreach (var e in hitObjectContainer.Entries)
119+
{
120+
if (!isAlreadyHit(e) && (candidate == null || e.HitObject.StartTime < candidate.HitObject.StartTime))
121+
candidate = e;
122+
}
123+
}
110124

111125
// In the case there are no non-judged objects, the last hit object should be used instead.
112126
if (candidate == null)
@@ -135,7 +149,23 @@ protected override void Update()
135149

136150
// Else we want the earliest valid nested.
137151
// In cases of nested objects, they will always have earlier sample data than their parent object.
138-
return getAllNested(mostValidObject.HitObject).OrderBy(h => h.GetEndTime()).SkipWhile(h => h.GetEndTime() <= getReferenceTime()).FirstOrDefault() ?? mostValidObject.HitObject;
152+
// Single-pass scan avoids the OrderBy + SkipWhile + FirstOrDefault LINQ chain.
153+
double referenceTime = getReferenceTime();
154+
HitObject? best = null;
155+
double bestEnd = double.MaxValue;
156+
157+
foreach (var nested in getAllNested(mostValidObject.HitObject))
158+
{
159+
double end = nested.GetEndTime();
160+
161+
if (end > referenceTime && end < bestEnd)
162+
{
163+
best = nested;
164+
bestEnd = end;
165+
}
166+
}
167+
168+
return best ?? mostValidObject.HitObject;
139169
}
140170

141171
private bool isAlreadyHit(HitObjectLifetimeEntry h) => h.AllJudged;

osu.Game/Rulesets/UI/HitObjectContainer.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
using System;
77
using System.Collections.Generic;
8-
using System.Linq;
98
using osu.Framework.Allocation;
109
using osu.Framework.Bindables;
1110
using osu.Framework.Extensions.TypeExtensions;
@@ -19,9 +18,42 @@ namespace osu.Game.Rulesets.UI
1918
{
2019
public partial class HitObjectContainer : PooledDrawableWithLifetimeContainer<HitObjectLifetimeEntry, DrawableHitObject>, IHitObjectContainer
2120
{
22-
public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
21+
/// <summary>
22+
/// All <see cref="DrawableHitObject"/>s in this container, sorted by ascending <see cref="HitObject.StartTime"/>.
23+
/// </summary>
24+
/// <remarks>
25+
/// Since internal children are already sorted by descending <see cref="HitObject.StartTime"/>
26+
/// (via <see cref="Compare"/>), we reverse-enumerate to avoid an O(n log n) sort on every access.
27+
/// </remarks>
28+
public IEnumerable<DrawableHitObject> Objects => enumerateByStartTimeAscending();
29+
30+
/// <summary>
31+
/// All alive <see cref="DrawableHitObject"/>s in this container, sorted by ascending <see cref="HitObject.StartTime"/>.
32+
/// </summary>
33+
/// <remarks>
34+
/// The alive entries dictionary is unordered, so we must sort.
35+
/// However, the alive set is typically much smaller than the full set, making this cheaper
36+
/// than sorting all children. We use a List + Sort (in-place) to avoid LINQ iterator allocations.
37+
/// </remarks>
38+
public IEnumerable<DrawableHitObject> AliveObjects => getSortedAliveObjects();
39+
40+
private IEnumerable<DrawableHitObject> enumerateByStartTimeAscending()
41+
{
42+
var children = InternalChildren;
43+
44+
for (int i = children.Count - 1; i >= 0; i--)
45+
{
46+
if (children[i] is DrawableHitObject hitObject)
47+
yield return hitObject;
48+
}
49+
}
2350

24-
public IEnumerable<DrawableHitObject> AliveObjects => AliveEntries.Values.OrderBy(h => h.HitObject.StartTime);
51+
private List<DrawableHitObject> getSortedAliveObjects()
52+
{
53+
var list = new List<DrawableHitObject>(AliveEntries.Values);
54+
list.Sort(static (a, b) => a.HitObject.StartTime.CompareTo(b.HitObject.StartTime));
55+
return list;
56+
}
2557

2658
/// <summary>
2759
/// Invoked when a <see cref="DrawableHitObject"/> is judged.

osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public HitObjectUsageEventBuffer([NotNull] Playfield playfield)
5959
private void onHitObjectUsageBegan(HitObject hitObject)
6060
{
6161
if (usageFinishedHitObjects.Remove(hitObject))
62-
HitObjectUsageTransferred?.Invoke(hitObject, playfield.AllHitObjects.Single(d => d.HitObject == hitObject));
62+
HitObjectUsageTransferred?.Invoke(hitObject, playfield.AllHitObjects.First(d => d.HitObject == hitObject));
6363
else
6464
HitObjectUsageBegan?.Invoke(hitObject);
6565
}

osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ void preventMiss(HitObject hitObject)
131131
{
132132
var drawableObject = DrawableRuleset.Playfield.HitObjectContainer
133133
.AliveObjects
134-
.SingleOrDefault(it => it.HitObject == hitObject);
134+
.FirstOrDefault(it => it.HitObject == hitObject);
135135

136136
if (drawableObject != null)
137137
preventMissOnDrawable(drawableObject);

osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondController.cs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,29 @@ protected override void Update()
3636
double latestValidTime = clock.CurrentTime;
3737
double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate();
3838

39+
// Timestamps are added in chronological order (from clock.CurrentTime),
40+
// so we can use binary-search-style trimming instead of per-element RemoveAt.
41+
42+
// Trim future timestamps caused by rewinding (remove from the end in one batch).
43+
// RemoveRange from the end is a single operation vs repeated RemoveAt calls.
44+
int trimStart = timestamps.Count;
45+
46+
while (trimStart > 0 && timestamps[trimStart - 1] > latestValidTime)
47+
trimStart--;
48+
49+
if (trimStart < timestamps.Count)
50+
timestamps.RemoveRange(trimStart, timestamps.Count - trimStart);
51+
52+
// Count timestamps within the valid 1-second window.
53+
// Since the list is in chronological order, scan backwards until we leave the window.
3954
int count = 0;
4055

4156
for (int i = timestamps.Count - 1; i >= 0; i--)
4257
{
43-
// handle rewinding by removing future timestamps as we go
44-
if (timestamps[i] > latestValidTime)
45-
{
46-
timestamps.RemoveAt(i);
47-
continue;
48-
}
49-
50-
if (timestamps[i] >= earliestTimeValid)
51-
count++;
58+
if (timestamps[i] < earliestTimeValid)
59+
break;
60+
61+
count++;
5262
}
5363

5464
Value = count;

osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,32 @@ public void Push(HitErrorShape shape)
115115

116116
private void removeExtraJudgements()
117117
{
118-
var remainingChildren = Children.Where(c => !c.IsRemoved);
118+
// Count non-removed children and remove excess starting from the oldest.
119+
// This avoids re-enumerating via LINQ .Count()/.First() on every iteration.
120+
int remaining = 0;
119121

120-
while (remainingChildren.Count() > JudgementCount.Value)
121-
remainingChildren.First().Remove();
122+
foreach (var c in Children)
123+
{
124+
if (!c.IsRemoved)
125+
remaining++;
126+
}
127+
128+
int target = JudgementCount.Value;
129+
130+
if (remaining <= target)
131+
return;
132+
133+
foreach (var c in Children)
134+
{
135+
if (remaining <= target)
136+
break;
137+
138+
if (!c.IsRemoved)
139+
{
140+
c.Remove();
141+
remaining--;
142+
}
143+
}
122144
}
123145

124146
private void updateMetrics()

0 commit comments

Comments
 (0)