Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f5c113
Fix non-default mod settings allowing for duplicate freestyle mod sel…
triacontakai May 7, 2026
7f385c7
Add better support for handling disconnection at the ranked play queu…
peppy May 7, 2026
1818c1b
Fix pause ambience loop not playing at fail screen (#37663)
peppy May 7, 2026
066ad47
Merge upstream ppy/osu master (3 commits: #37663 #37658 #37646) — pri…
Copilot May 7, 2026
80d7543
perf: LINQ→loop (BarHitErrorMeter), cache isAutoplayPlayback, Android…
Copilot May 7, 2026
6585152
merge: incorporate upstream ppy/osu content (CloudVisualisation, Scre…
Copilot May 7, 2026
4a57187
perf: hot-path alloc elimination batch (ScoreProcessor, DrawableSlide…
Copilot May 7, 2026
32c6732
perf: second-pass optimizations (LegacyHitPolicy single-pass, Drawabl…
Copilot May 7, 2026
7940a25
Perf: third-pass hot-path allocation fixes
Copilot May 7, 2026
4d7aa83
perf: third-pass - eliminate OfType LINQ allocations in OsuModRelax/S…
Copilot May 7, 2026
a6e65e8
fix: CI build errors + fourth-pass perf (IList cast, IDE0004/IDE0032,…
Copilot May 7, 2026
7bddedd
fix: CI errors batch 2 - missing using, nullable, pattern matching, r…
Copilot May 7, 2026
da43f16
fix: remove unused osu.Game.Audio using in DrawableSlider.cs (IDE0005)
Copilot May 7, 2026
17febd8
fix: add partial to AndroidBackgroundDataStoreProcessor (Android DI s…
Copilot May 8, 2026
3cd3d90
fix: resolve co-variant array conversion warnings (HitSampleInfo[] to…
Copilot May 8, 2026
31a95b0
fix: add missing `using osu.Game.Audio` to DrawableSlider.cs (CS0246 …
Copilot May 8, 2026
aa829ed
fix: add braces and line break for foreach/if in DrawableSlider (Insp…
Copilot May 8, 2026
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
27 changes: 27 additions & 0 deletions osu.Android/AndroidBackgroundDataStoreProcessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using osu.Game.Database;

namespace osu.Android
{
/// <summary>
/// Android-specific <see cref="BackgroundDataStoreProcessor"/> that extends the sleep
/// interval during active gameplay from the default 30 s to 2 minutes.
/// </summary>
/// <remarks>
/// On Android the high-performance session (<see cref="Performance.AndroidHighPerformanceSessionManager"/>)
/// flips <c>GCSettings.LatencyMode</c> to <c>SustainedLowLatency</c> for the entire gameplay window,
/// which suppresses Gen-2 (major) GC collections. The background processor's sleep loop also
/// suspends during gameplay, but wakes every <see cref="BackgroundDataStoreProcessor.TimeToSleepDuringGameplay"/>
/// ms to re-check the condition. Each wake-up incurs a managed thread resume + lock acquisition,
/// generating a small burst of GC-visible allocations. At 30 s those spurious wakes happen ~10×
/// per typical 5-minute play session; at 120 s they drop to ~2×, cutting the associated
/// allocation pressure and the risk of a GC stall at the worst possible moment.
/// </remarks>
public class AndroidBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
{
// 2-minute polling interval while gameplay is active (vs. the default 30 s).
protected override int TimeToSleepDuringGameplay => 120_000;
}
}
80 changes: 67 additions & 13 deletions osu.Android/Native/oboe_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,28 @@ bool OboeBridge::open(int32_t sampleRate) {
// MMAP provides direct access to audio hardware buffers, shaving ~1-2ms off latency.
oboe::OboeExtensions::setMMapEnabled(true);

// Initialise StabilizedCallback to even out callback execution time.
// shared_ptr is used to satisfy the non-deprecated setDataCallback overload.
stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);
// Non-owning shared_ptr for error callback — OboeBridge outlives the stream.
auto errorCb = std::shared_ptr<oboe::AudioStreamErrorCallback>(
std::shared_ptr<void>(), static_cast<oboe::AudioStreamErrorCallback*>(this));

// -----------------------------------------------------------------------
// Pass 1: open with a raw 'this' callback (no StabilizedCallback).
//
// On AAudio MMAP paths (Pixel 3+, Snapdragon 8 Gen 1+, most modern
// Android), the kernel delivers audio callbacks with near-perfect timing
// via the hardware FIFO interrupt. StabilizedCallback works by sleeping
// on the callback thread to normalise jitter — on MMAP that sleep is pure
// overhead: it delays the write to the hardware ring buffer, adding latency
// without removing any real jitter.
//
// We probe by opening with the raw callback first. If MMAP is confirmed
// we keep this stream. If not, we close it and reopen with
// StabilizedCallback (see Pass 2 below) to cover the non-MMAP / OpenSL ES
// fallback path where OS scheduler jitter is real.
// -----------------------------------------------------------------------
stabilizedCallback_.reset();
auto rawCb = std::shared_ptr<oboe::AudioStreamDataCallback>(
std::shared_ptr<void>(), static_cast<oboe::AudioStreamDataCallback*>(this));

oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
Expand All @@ -120,18 +139,50 @@ bool OboeBridge::open(int32_t sampleRate) {
->setIsContentSpatialized(true)
// Prevent other apps from capturing our audio stream (competitive integrity).
->setAllowedCapturePolicy(oboe::AllowedCapturePolicy::None)
// Use shared_ptr overload (non-deprecated) for data callback.
->setDataCallback(stabilizedCallback_)
// Non-owning shared_ptr for error callback — OboeBridge outlives the stream.
->setErrorCallback(std::shared_ptr<oboe::AudioStreamErrorCallback>(
std::shared_ptr<void>(), static_cast<oboe::AudioStreamErrorCallback*>(this)));
->setDataCallback(rawCb)
->setErrorCallback(errorCb);

oboe::Result result = builder.openStream(stream_);

if (result != oboe::Result::OK) {
if (result == oboe::Result::OK) {
bool mmapActive = oboe::OboeExtensions::isMMapUsed(stream_.get());
LOGI("Oboe pass-1 open: MMAP=%s", mmapActive ? "yes" : "no");

if (!mmapActive) {
// ---------------------------------------------------------------
// Pass 2: MMAP unavailable — close and reopen with StabilizedCallback.
// StabilizedCallback adds a compensating sleep to normalise the
// variable latency introduced by the OS scheduler on non-MMAP paths,
// reducing buffer underruns on devices that rely on AAudio binder IPC
// or the OpenSL ES compatibility layer.
// ---------------------------------------------------------------
stream_->close();
stream_.reset();

stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);

builder.setDataCallback(stabilizedCallback_);
result = builder.openStream(stream_);

if (result != oboe::Result::OK) {
LOGE("AAudio + StabilizedCallback open failed (%s), falling back to unspecified API",
oboe::convertToText(result));
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); }
builder.setAudioApi(oboe::AudioApi::Unspecified);
builder.setSharingMode(oboe::SharingMode::Shared);
result = builder.openStream(stream_);
}
}
} else {
// AAudio exclusive failed outright — try unspecified API + shared mode.
// Always wrap with StabilizedCallback on this fallback path since we
// almost certainly won't have MMAP on an OpenSL ES device.
LOGE("AAudio open failed (%s), falling back to unspecified API",
oboe::convertToText(result));
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); }

stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);
builder.setDataCallback(stabilizedCallback_);
builder.setAudioApi(oboe::AudioApi::Unspecified);
builder.setSharingMode(oboe::SharingMode::Shared);
result = builder.openStream(stream_);
Expand Down Expand Up @@ -168,14 +219,15 @@ bool OboeBridge::open(int32_t sampleRate) {
tuner_ = std::make_unique<oboe::LatencyTuner>(*stream_);

LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, "
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s",
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s, stabilized=%s",
stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES",
stream_->getSampleRate(),
stream_->getFramesPerBurst(),
stream_->getBufferSizeInFrames(),
stream_->getBufferCapacityInFrames(),
stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared",
oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no");
oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no",
stabilizedCallback_ ? "yes" : "no");

return true;
}
Expand Down Expand Up @@ -302,8 +354,10 @@ oboe::DataCallbackResult OboeBridge::onAudioReady(
framesRead = std::clamp(framesRead, 0, numFrames);

if (framesRead < numFrames) {
size_t bytesDone = static_cast<size_t>(framesRead) * stream->getChannelCount() * sizeof(float);
size_t totalBytes = static_cast<size_t>(numFrames) * stream->getChannelCount() * sizeof(float);
// Cache channel count in a local to avoid two virtual dispatches.
int32_t ch = stream->getChannelCount();
size_t bytesDone = static_cast<size_t>(framesRead) * ch * sizeof(float);
size_t totalBytes = static_cast<size_t>(numFrames) * ch * sizeof(float);
memset(static_cast<char*>(audioData) + bytesDone, 0, totalBytes - bytesDone);
}
} else {
Expand Down
16 changes: 11 additions & 5 deletions osu.Android/OsuGameAndroid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using osu.Framework.Platform;
using osu.Game;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
using osu.Game.Screens;
Expand Down Expand Up @@ -186,6 +187,9 @@ public partial class OsuGameAndroid : OsuGame
private IntPtr adpfInputSession;
private double inputAdpfAccumulatedMs;
private long inputAdpfLastReportMs; // Environment.TickCount64 ms timestamp of last ADPF report
// Cached display-frame period in ms. Recomputed in applyDisplayMode so the hot
// input callback (which can fire at ~100 kHz) reads a plain field, not Math.Round.
private long adpfInputIntervalMs = 8L;

// One-shot System.Threading.Timer that runs a burst of background-thread taming passes
// when the user transitions into active gameplay. Cancelled and replaced on each new
Expand Down Expand Up @@ -1488,6 +1492,8 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
try
{
currentRefreshRate = (int)mode.RefreshRate;
// Cache the interval so the input hot-path avoids Math.Round on every poll.
adpfInputIntervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L;

// Request the refresh rate via Surface.setFrameRate() ONLY.
//
Expand Down Expand Up @@ -1610,13 +1616,11 @@ private void onInputFrameCompleted()

inputAdpfAccumulatedMs += elapsedMs;

// Report once per display frame period. `currentRefreshRate` is an int written
// from the UI thread — a torn or stale read is harmless (worst-case we use a
// slightly wrong interval for one report cycle).
long intervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L;
// Report once per display frame period. `adpfInputIntervalMs` is pre-computed
// in applyDisplayMode() so this callback reads a plain field instead of calling Math.Round.
long nowMs = System.Environment.TickCount64;

if (nowMs - inputAdpfLastReportMs >= intervalMs)
if (nowMs - inputAdpfLastReportMs >= adpfInputIntervalMs)
{
OboeAudioBridge.nADPFReportActualDuration(adpfInputSession, (long)(inputAdpfAccumulatedMs * 1_000_000.0));
inputAdpfAccumulatedMs = 0;
Expand Down Expand Up @@ -2725,6 +2729,8 @@ private static bool isFrameworkDuplicateOfAndroidHandler(osu.Framework.Input.Han

protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();

protected override BackgroundDataStoreProcessor CreateBackgroundDataStoreProcessor() => new AndroidBackgroundDataStoreProcessor();

protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo();

protected override void Dispose(bool isDisposing)
Expand Down
13 changes: 12 additions & 1 deletion osu.Game.Rulesets.Catch/UI/Catcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius)
float adjustedRadius = displayRadius * lenience_adjust;
float checkDistance = MathF.Pow(adjustedRadius, 2);

while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance))
while (tooCloseToExistingObject(position, checkDistance))
{
position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius);
position.Y -= RNG.NextSingle(0, 5);
Expand All @@ -399,6 +399,17 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius)
return position;
}

private bool tooCloseToExistingObject(Vector2 position, float checkDistance)
{
foreach (var f in caughtObjectContainer)
{
if (Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)
return true;
}

return false;
}

private void addLighting(JudgementResult judgementResult, Color4 colour, float x) =>
hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, judgementResult, colour, x));

Expand Down
12 changes: 11 additions & 1 deletion osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,17 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawableObject)
{
if (drawable.HitObject is SpinnerTick or Slider) return;

BubbleDrawable? lastBubble = bubbleContainer.OfType<BubbleDrawable>().LastOrDefault();
// Reverse linear scan avoids the OfType<BubbleDrawable>().LastOrDefault() LINQ allocations.
BubbleDrawable? lastBubble = null;

for (int i = bubbleContainer.Children.Count - 1; i >= 0; i--)
{
if (bubbleContainer.Children[i] is BubbleDrawable b)
{
lastBubble = b;
break;
}
}

lastBubble?.ClearTransforms();
lastBubble?.Expire(true);
Expand Down
6 changes: 5 additions & 1 deletion osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,12 @@ public void Update(Playfield playfield)

double time = playfield.Clock.CurrentTime;

foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType<DrawableOsuHitObject>())
foreach (DrawableHitObject dho in playfield.HitObjectContainer.AliveObjects)
{
// All alive objects in an osu! playfield are DrawableOsuHitObjects.
// Casting inline avoids the OfType<> LINQ state-machine allocation per frame.
if (dho is not DrawableOsuHitObject h)
continue;
// we are not yet close enough to the object.
if (time < h.HitObject.StartTime - RELAX_LENIENCY)
break;
Expand Down
14 changes: 12 additions & 2 deletions osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,19 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawable)
if (slider.Clock is IGameplayClock { IsRewinding: true })
return;

var tail = slider.NestedHitObjects.OfType<StrictTrackingDrawableSliderTail>().First();
// Manual scan avoids allocating a LINQ state-machine on every tracking-change event.
StrictTrackingDrawableSliderTail? tail = null;

if (!tail.Judged)
foreach (var nested in slider.NestedHitObjects)
{
if (nested is StrictTrackingDrawableSliderTail t)
{
tail = t;
break;
}
}

if (tail != null && !tail.Judged)
tail.MissForcefully();
};
}
Expand Down
5 changes: 4 additions & 1 deletion osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircl
public HitReceptor HitArea { get; private set; } = null!;
public SkinnableDrawable CirclePiece { get; private set; } = null!;

protected override IEnumerable<Drawable> DimmablePieces => new[] { CirclePiece };
protected override IEnumerable<Drawable> DimmablePieces => dimmablePieces;
private Drawable[] dimmablePieces = null!;

Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;

Expand Down Expand Up @@ -96,6 +97,8 @@ private void load()

Size = HitArea.DrawSize;

dimmablePieces = new Drawable[] { CirclePiece };

PositionBindable.BindValueChanged(_ => UpdatePosition());
StackHeightBindable.BindValueChanged(_ => UpdatePosition());
ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,11 @@ protected override void ClearNestedHitObjects()
// and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use.
// therefore, clean up the subscription here to avoid crosstalk.
// not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar).
foreach (var piece in DimmablePieces.OfType<DrawableHitObject>())
piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
foreach (var piece in DimmablePieces)
{
if (piece is DrawableHitObject dho)
dho.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
}
}

private void applyDim(Drawable piece)
Expand Down
24 changes: 20 additions & 4 deletions osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,10 @@ protected override void LoadSamples()
{
// Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.

Samples.Samples = HitObject.TailSamples.Cast<ISampleInfo>().ToArray();
slidingSample.Samples = HitObject.CreateSlidingSamples().Cast<ISampleInfo>().ToArray();
// HitSampleInfo : ISampleInfo (reference type) — array covariance lets us cast directly,
// avoiding a second array allocation from .Cast<ISampleInfo>().ToArray().
Samples.Samples = (ISampleInfo[])HitObject.TailSamples.ToArray();
slidingSample.Samples = (ISampleInfo[])HitObject.CreateSlidingSamples().ToArray();
}

public override void StopAllSamples()
Expand Down Expand Up @@ -301,7 +303,10 @@ protected override void CheckForResult(bool userTriggered, double timeOffset)
ApplyResult(static (r, hitObject) =>
{
int totalTicks = hitObject.NestedHitObjects.Count;
int hitTicks = hitObject.NestedHitObjects.Count(h => h.IsHit);
int hitTicks = 0;

foreach (var h in hitObject.NestedHitObjects)
if (h.IsHit) hitTicks++;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

if (hitTicks == totalTicks)
r.Type = HitResult.Great;
Expand All @@ -320,7 +325,18 @@ protected override void CheckForResult(bool userTriggered, double timeOffset)
// But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc).
ApplyResult(static (r, hitObject) =>
{
r.Type = hitObject.NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult;
bool anyHit = false;

foreach (var h in hitObject.NestedHitObjects)
{
if (h.Result.IsHit)
{
anyHit = true;
break;
}
}

r.Type = anyHit ? r.Judgement.MaxResult : r.Judgement.MinResult;
});
}
}
Expand Down
Loading
Loading