Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ jobs:
with:
dotnet-version: "10.0.x"

- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
restore-keys: ${{ runner.os }}-nuget-

- name: Restore Tools
run: dotnet tool restore

Expand Down Expand Up @@ -94,6 +101,13 @@ jobs:
with:
dotnet-version: "10.0.x"

- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
restore-keys: ${{ runner.os }}-nuget-

- name: Compile
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf

Expand Down Expand Up @@ -171,6 +185,13 @@ jobs:
with:
dotnet-version: "10.0.x"

- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
restore-keys: ${{ runner.os }}-nuget-

- name: Install .NET workloads
run: dotnet workload install android

Expand All @@ -192,6 +213,14 @@ jobs:
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"

- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
restore-keys: ${{ runner.os }}-nuget-

- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.3.app

Expand Down
4 changes: 4 additions & 0 deletions osu.Game/Online/OnlineStatusNotifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

apiState.UnbindAll();
multiplayerState.UnbindAll();
spectatorState.UnbindAll();
Comment on lines +164 to +166

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These bindables are initialised in load() via GetBoundCopy(), but Dispose() can run even if load() never executed (note the existing IsNotNull() guards for clients). Calling UnbindAll() unconditionally here can therefore throw if any of these fields are still null at disposal time. Consider initialising them at declaration (e.g. new Bindable<...>() + BindTo(...) in load()), or making them nullable and using null-conditional unbinds.

Suggested change
apiState.UnbindAll();
multiplayerState.UnbindAll();
spectatorState.UnbindAll();
apiState?.UnbindAll();
multiplayerState?.UnbindAll();
spectatorState?.UnbindAll();

Copilot uses AI. Check for mistakes.

if (notificationsClient.IsNotNull())
notificationsClient.MessageReceived -= notifyAboutForcedDisconnection;

Expand Down
17 changes: 14 additions & 3 deletions osu.Game/Rulesets/Scoring/HitEventExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,23 @@ public static class HitEventExtensions
/// </returns>
public static double? CalculateAverageHitError(this IEnumerable<HitEvent> hitEvents)
{
double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray();
// Single-pass mean using running sum — avoids allocating a temporary array.
double sum = 0;
int count = 0;

if (timeOffsets.Length == 0)
foreach (var ev in hitEvents)
{
if (!AffectsUnstableRate(ev))
continue;

sum += ev.TimeOffset;
count++;
}

if (count == 0)
return null;

return timeOffsets.Average();
return sum / count;
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/Rulesets/UI/DrawableRuleset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ public bool RemoveHitObject(TObject hitObject)
return true;

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

Expand Down
46 changes: 38 additions & 8 deletions osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ public virtual void Play()
if (nextObject == null)
return;

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

PlaySamples(samples);
}
Expand Down Expand Up @@ -103,10 +102,25 @@ protected override void Update()
{
// 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).
// If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
var candidate =
// Use alive entries first as an optimisation.
hitObjectContainer.AliveEntries.Keys.Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime)
?? hitObjectContainer.Entries.Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime);

// Use alive entries first as an optimisation (single-pass minimum, no LINQ allocation).
HitObjectLifetimeEntry? candidate = null;

foreach (var e in hitObjectContainer.AliveEntries.Keys)
{
if (!isAlreadyHit(e) && (candidate == null || e.HitObject.StartTime < candidate.HitObject.StartTime))
candidate = e;
}

// Fall back to full entries if no alive non-judged entry found.
if (candidate == null)
{
foreach (var e in hitObjectContainer.Entries)
{
if (!isAlreadyHit(e) && (candidate == null || e.HitObject.StartTime < candidate.HitObject.StartTime))
candidate = e;
}
}

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

// Else we want the earliest valid nested.
// In cases of nested objects, they will always have earlier sample data than their parent object.
return getAllNested(mostValidObject.HitObject).OrderBy(h => h.GetEndTime()).SkipWhile(h => h.GetEndTime() <= getReferenceTime()).FirstOrDefault() ?? mostValidObject.HitObject;
// Single-pass scan avoids the OrderBy + SkipWhile + FirstOrDefault LINQ chain.
double referenceTime = getReferenceTime();
HitObject? best = null;
double bestEnd = double.MaxValue;

foreach (var nested in getAllNested(mostValidObject.HitObject))
{
double end = nested.GetEndTime();

if (end > referenceTime && end < bestEnd)
{
best = nested;
bestEnd = end;
}
}

return best ?? mostValidObject.HitObject;
}

private bool isAlreadyHit(HitObjectLifetimeEntry h) => h.AllJudged;
Expand Down
38 changes: 35 additions & 3 deletions osu.Game/Rulesets/UI/HitObjectContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
Expand All @@ -19,9 +18,42 @@ namespace osu.Game.Rulesets.UI
{
public partial class HitObjectContainer : PooledDrawableWithLifetimeContainer<HitObjectLifetimeEntry, DrawableHitObject>, IHitObjectContainer
{
public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
/// <summary>
/// All <see cref="DrawableHitObject"/>s in this container, sorted by ascending <see cref="HitObject.StartTime"/>.
/// </summary>
/// <remarks>
/// Since internal children are already sorted by descending <see cref="HitObject.StartTime"/>
/// (via <see cref="Compare"/>), we reverse-enumerate to avoid an O(n log n) sort on every access.
/// </remarks>
public IEnumerable<DrawableHitObject> Objects => enumerateByStartTimeAscending();

/// <summary>
/// All alive <see cref="DrawableHitObject"/>s in this container, sorted by ascending <see cref="HitObject.StartTime"/>.
/// </summary>
/// <remarks>
/// The alive entries dictionary is unordered, so we must sort.
/// However, the alive set is typically much smaller than the full set, making this cheaper
/// than sorting all children. We use a List + Sort (in-place) to avoid LINQ iterator allocations.
/// </remarks>
public IEnumerable<DrawableHitObject> AliveObjects => getSortedAliveObjects();

private IEnumerable<DrawableHitObject> enumerateByStartTimeAscending()
{
var children = InternalChildren;

for (int i = children.Count - 1; i >= 0; i--)
{
if (children[i] is DrawableHitObject hitObject)
yield return hitObject;
}
}

public IEnumerable<DrawableHitObject> AliveObjects => AliveEntries.Values.OrderBy(h => h.HitObject.StartTime);
private List<DrawableHitObject> getSortedAliveObjects()
{
var list = new List<DrawableHitObject>(AliveEntries.Values);
list.Sort(static (a, b) => a.HitObject.StartTime.CompareTo(b.HitObject.StartTime));
return list;
}

/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is judged.
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public HitObjectUsageEventBuffer([NotNull] Playfield playfield)
private void onHitObjectUsageBegan(HitObject hitObject)
{
if (usageFinishedHitObjects.Remove(hitObject))
HitObjectUsageTransferred?.Invoke(hitObject, playfield.AllHitObjects.Single(d => d.HitObject == hitObject));
HitObjectUsageTransferred?.Invoke(hitObject, playfield.AllHitObjects.First(d => d.HitObject == hitObject));
else
HitObjectUsageBegan?.Invoke(hitObject);
}
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ void preventMiss(HitObject hitObject)
{
var drawableObject = DrawableRuleset.Playfield.HitObjectContainer
.AliveObjects
.SingleOrDefault(it => it.HitObject == hitObject);
.FirstOrDefault(it => it.HitObject == hitObject);

if (drawableObject != null)
preventMissOnDrawable(drawableObject);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,29 @@ protected override void Update()
double latestValidTime = clock.CurrentTime;
double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate();

// Timestamps are added in chronological order (from clock.CurrentTime),
// so we can use binary-search-style trimming instead of per-element RemoveAt.

// Trim future timestamps caused by rewinding (remove from the end in one batch).
// RemoveRange from the end is a single operation vs repeated RemoveAt calls.
int trimStart = timestamps.Count;

while (trimStart > 0 && timestamps[trimStart - 1] > latestValidTime)
trimStart--;

if (trimStart < timestamps.Count)
timestamps.RemoveRange(trimStart, timestamps.Count - trimStart);

Comment on lines +39 to +51

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new trimming/counting logic assumes timestamps is always in non-decreasing order and that any "future" timestamps (from rewinds/seeks) are contiguous at the end. If an input timestamp is added after a seek/rewind but before the next Update() (or if replay input is re-applied after rewinding), the list can become out-of-order and this RemoveRange-from-end pass will leave future timestamps earlier in the list, inflating the CPS count. Consider either maintaining sorted order on insertion (e.g. insert at the correct position) or falling back to removing future timestamps during the backwards scan when an out-of-order condition is detected (or clearing the list on rewind/seek).

Copilot uses AI. Check for mistakes.
// Count timestamps within the valid 1-second window.
// Since the list is in chronological order, scan backwards until we leave the window.
int count = 0;

for (int i = timestamps.Count - 1; i >= 0; i--)
{
// handle rewinding by removing future timestamps as we go
if (timestamps[i] > latestValidTime)
{
timestamps.RemoveAt(i);
continue;
}

if (timestamps[i] >= earliestTimeValid)
count++;
if (timestamps[i] < earliestTimeValid)
break;

count++;
}

Value = count;
Expand Down
28 changes: 25 additions & 3 deletions osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,32 @@ public void Push(HitErrorShape shape)

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

while (remainingChildren.Count() > JudgementCount.Value)
remainingChildren.First().Remove();
foreach (var c in Children)
{
if (!c.IsRemoved)
remaining++;
}

int target = JudgementCount.Value;

if (remaining <= target)
return;

foreach (var c in Children)
{
if (remaining <= target)
break;

if (!c.IsRemoved)
{
c.Remove();
remaining--;
}
}
}

private void updateMetrics()
Expand Down
2 changes: 1 addition & 1 deletion submodules/osu-framework
Loading