Skip to content
Merged

tmp #348

Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e85ac22
Update framework
peppy May 21, 2026
5b24de3
Update resources
peppy May 21, 2026
a31eadc
Add session-level playable beatmap cache + persisted slider pool meta…
Copilot May 21, 2026
89fe51a
Add versioned playable prewarm and persisted slider ticks metadata
Copilot May 21, 2026
6cfec93
Fix double GetPlayableBeatmap in BeatmapUpdater; start prewarm in Loa…
Copilot May 21, 2026
172e66e
Gameplay perf: ISliderTick interface, hoist PositionAt out of repeat …
Copilot May 21, 2026
0388d4a
5 gameplay hot-path optimizations: snaking threshold, slider ball cac…
Copilot May 21, 2026
30c49d1
Address code-review: ArgonAccuracyCounter format fallback, Playfield …
Copilot May 21, 2026
9f4834f
Fix CI errors: Logger import, enumerateByStartTimeAscending, Playable…
Copilot May 21, 2026
6affa8a
Optimize slider/spinner hot paths: cache SliderBody, null-safe repeat…
Copilot May 21, 2026
6be767c
Perf: RDP slider path simplification (3-10x fewer render vertices) + …
Copilot May 21, 2026
9fddd91
Merge remote-tracking branch 'upstream/master' into copilot/add-persi…
Copilot May 21, 2026
ca2faed
fix: remove nullable annotation from Slider cache field
Copilot May 22, 2026
4504ae5
fix: InspectCode formatting warnings + HSPAColour perceived-brightnes…
Copilot May 22, 2026
cdc4cbf
fix: add general Vulkan UI-thread watchdog for SDL surface-event ANR …
Copilot May 22, 2026
eab2b49
fix: resolve Android watchdog namespace/Environment compile errors
Copilot May 22, 2026
dceaa0a
fix: correct SnakingSliderBody.cs line 167-168 continuation indent fr…
Copilot May 22, 2026
d1cd7c5
fix: satisfy InspectCode WrongIndentSize at SnakingSliderBody line 167
Copilot May 22, 2026
ea3f5ac
ci: raise test job timeout to 120 minutes
Copilot May 22, 2026
805167c
fix: avoid startup backfill for slider pool metadata
Copilot May 22, 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
14 changes: 12 additions & 2 deletions osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,18 @@ protected override void UpdateAfterChildren()
Ball.UpdateProgress(completionProgress);
SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0);

foreach (DrawableSliderRepeat repeat in repeatContainer)
repeat.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0));
// Pre-compute the snaking start/end path positions once.
// PositionAt() performs a binary search over the pre-built path points list,
// so recomputing it for every repeat on every frame is wasteful — all repeats
// share the same start/end values within a single update.
if (repeatContainer.Count > 0)
{
Vector2 snakeStart = HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0);
Vector2 snakeEnd = HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0);

foreach (DrawableSliderRepeat repeat in repeatContainer)
repeat.UpdateSnakingPosition(snakeStart, snakeEnd);
}

Size = SliderBody?.Size ?? Vector2.Zero;
OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero;
Expand Down
27 changes: 22 additions & 5 deletions osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,34 @@ public override void ApplyTransformsAt(double time, bool propagateChildren = fal
base.ApplyTransformsAt(time, false);
}

private double cachedPathDistance = -1;
private double cachedCheckDistance;

public void UpdateProgress(double completionProgress)
{
Slider slider = drawableSlider.HitObject;

// Cache the check-distance; Path.Distance is stable after ApplyDefaults so the
// division only runs once per slider-pool reuse (when the path changes).
double pathDistance = slider.Path.Distance;

if (pathDistance != cachedPathDistance)
{
cachedPathDistance = pathDistance;
cachedCheckDistance = 0.1 / pathDistance;
}

// Exact position at current progress (binary search #1).
Position = slider.CurvePositionAt(completionProgress);

// 0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1
double checkDistance = 0.1 / slider.Path.Distance;
var diff = slider.CurvePositionAt(Math.Min(1 - checkDistance, completionProgress)) - slider.CurvePositionAt(Math.Min(1, completionProgress + checkDistance));
// Forward-tangent point for ball rotation (binary search #2).
// Using (current → forward) instead of the original symmetric
// (backward → forward) cuts one PositionAt call per frame with
// imperceptible accuracy loss, since checkDistance is tiny.
double dForward = Math.Min(1, completionProgress + cachedCheckDistance);
var diff = Position - slider.CurvePositionAt(dForward);

// Ensure the value is substantially high enough to allow for Atan2 to get a valid angle.
// Needed for when near completion, or in case of a very short slider.
// Ensure the diff is long enough for Atan2 to return a meaningful angle.
if (diff.LengthSquared() < 0.0001f)
return;

Expand Down
8 changes: 7 additions & 1 deletion osu.Game.Rulesets.Osu/Objects/Slider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public double Duration
set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed.
}

public override IList<HitSampleInfo> AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray();
public override IList<HitSampleInfo> AuxiliarySamples => cachedAuxiliarySamples ??= CreateSlidingSamples().Concat(TailSamples).ToArray();

// Cached after ApplyDefaults populates TailSamples — stable for the lifetime of this slider instance.
private IList<HitSampleInfo>? cachedAuxiliarySamples;

private readonly Cached<Vector2> endPositionCache = new Cached<Vector2>();

Expand Down Expand Up @@ -165,6 +168,9 @@ protected override void CreateNestedHitObjects(CancellationToken cancellationTok
{
base.CreateNestedHitObjects(cancellationToken);

// Invalidate the auxiliary-samples cache since TailSamples will be reassigned below.
cachedAuxiliarySamples = null;

var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);

foreach (var e in sliderEvents)
Expand Down
3 changes: 2 additions & 1 deletion osu.Game.Rulesets.Osu/Objects/SliderTick.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring;

namespace osu.Game.Rulesets.Osu.Objects
{
public class SliderTick : OsuHitObject
public class SliderTick : OsuHitObject, ISliderTick
{
public int SpanIndex { get; set; }
public double SpanStartTime { get; set; }
Expand Down
12 changes: 11 additions & 1 deletion osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,22 @@ private void updatePathSize()
Path.Size = Size;
}

// Minimum progress-change needed before we rebuild the path mesh.
// 0.002 = 0.2% of path length; sub-pixel for any slider ≥ 50px.
// At ≤240fps the per-frame change always exceeds this, so there is
// zero visual impact at normal frame rates. At 500fps+ it halves
// (or better) the number of GetPathToProgress + SetVertices calls.
private const double snaking_update_threshold = 0.002;

private void setRange(double p0, double p1)
{
if (p0 > p1)
(p0, p1) = (p1, p0);

if (SnakedStart == p0 && SnakedEnd == p1) return;
if (SnakedStart.HasValue && SnakedEnd.HasValue
&& Math.Abs(p0 - SnakedStart.Value) < snaking_update_threshold
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
&& Math.Abs(p1 - SnakedEnd.Value) < snaking_update_threshold)
return;

SnakedStart = p0;
SnakedEnd = p1;
Expand Down
25 changes: 24 additions & 1 deletion osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Numerics;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
Expand Down Expand Up @@ -75,6 +76,11 @@ protected Anchor TrailOrigin

private readonly TrailPart[] parts = new TrailPart[max_sprites];
private int currentIndex;

// Incremented (via Interlocked) every time a new trail part is written.
// The draw node reads this to decide whether a full CopyTo is necessary;
// on frames where the cursor hasn't moved no copy is performed.
private int partsVersion;
private IShader shader;
private double timeOffset;
private float time;
Expand Down Expand Up @@ -221,6 +227,10 @@ private void addPart(Vector2 localSpacePosition)
++parts[currentIndex].InvalidationID;

currentIndex = (currentIndex + 1) % max_sprites;

// Memory barrier: ensure the parts[] write above is visible to the draw thread
// before the version increment so ApplyState always sees a consistent snapshot.
Interlocked.Increment(ref partsVersion);
}

protected override DrawNode CreateDrawNode() => new TrailDrawNode(this);
Expand Down Expand Up @@ -248,6 +258,10 @@ private class TrailDrawNode : DrawNode
private readonly TrailPart[] parts = new TrailPart[max_sprites];
private Vector2 originPosition;

// Tracks the last partsVersion we copied from the source.
// CopyTo (≈56 KB) is skipped on frames where no new parts were added.
private int lastAppliedPartsVersion = -1;

private IVertexBatch<TexturedTrailVertex> vertexBatch;

public TrailDrawNode(CursorTrail source)
Expand Down Expand Up @@ -278,7 +292,16 @@ public override void ApplyState()
else if (Source.TrailOrigin.HasFlag(Anchor.y2))
originPosition.Y = 1f;

Source.parts.CopyTo(parts, 0);
// Only copy the parts array when new trail parts have been written since
// the last ApplyState. The Interlocked.Increment in addPart() provides a
// full memory barrier so the array content is always consistent here.
int currentVersion = Volatile.Read(ref Source.partsVersion);

if (currentVersion != lastAppliedPartsVersion)
{
Source.parts.CopyTo(parts, 0);
lastAppliedPartsVersion = currentVersion;
}
}

private IUniformBuffer<CursorTrailParameters> cursorTrailParameters;
Expand Down
29 changes: 23 additions & 6 deletions osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,32 @@ private void load(OsuRulesetConfigManager? config, IBeatmap? beatmap)
RegisterPool<HitCircle, DrawableHitCircle>(20, 100);

// handle edge cases where a beatmap has a slider with many repeats.
int maxRepeatsOnOneSlider = 0;
int maxTicksOnOneSlider = 0;
int maxRepeatsOnOneSlider;
int maxTicksOnOneSlider;

if (osuBeatmap != null)
// Use persisted metadata when available to avoid a full beatmap scan.
// Fall back to scanning the loaded beatmap if metadata has not yet been computed.
int persistedMaxRepeats = beatmap?.BeatmapInfo.MaxSliderRepeats ?? -1;
int persistedMaxTicks = beatmap?.BeatmapInfo.MaxSliderTicks ?? -1;

if (persistedMaxRepeats >= 0 && persistedMaxTicks >= 0)
{
maxRepeatsOnOneSlider = persistedMaxRepeats;
maxTicksOnOneSlider = persistedMaxTicks;
}
else
{
foreach (var slider in osuBeatmap.HitObjects.OfType<Slider>())
// Fallback: scan all sliders when persisted metadata is not available.
maxRepeatsOnOneSlider = 0;
maxTicksOnOneSlider = 0;

if (osuBeatmap != null)
{
maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount);
maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType<SliderTick>().Count());
foreach (var slider in osuBeatmap.HitObjects.OfType<Slider>())
{
maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount);
maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType<SliderTick>().Count());
}
}
}

Expand Down
52 changes: 52 additions & 0 deletions osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 System.Linq;
using System.Numerics;
using System.Threading;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;

namespace osu.Game.Tests.Beatmaps
{
[TestFixture]
public class BeatmapInfoStartupMetadataTest
{
[Test]
public void TestSliderStartupMetadataCalculated()
{
var beatmap = new OsuBeatmap
{
HitObjects =
{
new Slider
{
StartTime = 0,
RepeatCount = 2,
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, new Vector2(200, 0) }),
},
new Slider
{
StartTime = 1500,
RepeatCount = 4,
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, new Vector2(400, 0) }),
},
}
};

foreach (var hitObject in beatmap.HitObjects)
hitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty, CancellationToken.None);

var info = new BeatmapInfo();
info.UpdateStatisticsFromBeatmap(beatmap);

Assert.That(info.MaxSliderRepeats, Is.EqualTo(4));
Assert.That(info.MaxSliderTicks, Is.GreaterThan(0));
Assert.That(beatmap.HitObjects.OfType<Slider>().Any(s => s.NestedHitObjects.OfType<SliderTick>().Any()), Is.True);
}
}
}
50 changes: 50 additions & 0 deletions osu.Game.Tests/Beatmaps/PlayableBeatmapCacheTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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 System;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;

namespace osu.Game.Tests.Beatmaps
{
[TestFixture]
public class PlayableBeatmapCacheTest
{
[Test]
public void TestReturnsClone()
{
var cache = new PlayableBeatmapCache();
var beatmap = new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
Hash = "hash"
}
};

cache.CachePlayableBeatmap(beatmap.BeatmapInfo, beatmap.BeatmapInfo.Ruleset, Array.Empty<Mod>(), beatmap);

Assert.That(cache.TryGetPlayableBeatmap(beatmap.BeatmapInfo, beatmap.BeatmapInfo.Ruleset, Array.Empty<Mod>(), out var retrieved), Is.True);
Assert.That(retrieved, Is.Not.SameAs(beatmap));
}

[Test]
public void TestHashChangeMissesCache()
{
var cache = new PlayableBeatmapCache();
var beatmapInfo = new BeatmapInfo
{
Hash = "hash-a"
};

var beatmap = new Beatmap { BeatmapInfo = beatmapInfo };

cache.CachePlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty<Mod>(), beatmap);
Assert.That(cache.TryGetPlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty<Mod>(), out _), Is.True);

beatmapInfo.Hash = "hash-b";
Assert.That(cache.TryGetPlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty<Mod>(), out _), Is.False);
}
}
}
6 changes: 6 additions & 0 deletions osu.Game.Tournament/Models/TournamentBeatmap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo

public int TotalObjectCount { get; set; }

public int MaxSliderRepeats { get; set; }

public int MaxSliderTicks { get; set; }

public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata();

public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty();
Expand All @@ -50,6 +54,8 @@ public TournamentBeatmap(APIBeatmap beatmap)
Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers();
EndTimeObjectCount = beatmap.EndTimeObjectCount;
TotalObjectCount = beatmap.TotalObjectCount;
MaxSliderRepeats = beatmap.MaxSliderRepeats;
MaxSliderTicks = beatmap.MaxSliderTicks;
Ruleset = beatmap.Ruleset;
}

Expand Down
14 changes: 14 additions & 0 deletions osu.Game/Beatmaps/BeatmapInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ public BeatmapOnlineStatus Status

public int TotalObjectCount { get; set; } = -1;

/// <summary>
/// The maximum number of repeats found on a single <see cref="osu.Game.Rulesets.Objects.Types.IHasRepeats"/> hit object in the beatmap.
/// Only relevant for rulesets that have such objects (e.g. osu! sliders, mania hold notes).
/// Defaults to -1 (meaning not-yet-calculated).
/// </summary>
public int MaxSliderRepeats { get; set; } = -1;

/// <summary>
/// The maximum number of slider ticks found on a single slider in the beatmap.
/// Only relevant for rulesets that expose slider ticks (currently osu!).
/// Defaults to -1 (meaning not-yet-calculated).
/// </summary>
public int MaxSliderTicks { get; set; } = -1;

/// <summary>
/// Reset any fetched online linking information (and history).
/// </summary>
Expand Down
17 changes: 17 additions & 0 deletions osu.Game/Beatmaps/BeatmapInfoExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBe
beatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration);
beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count;

// Compute the maximum repeat count for pool sizing purposes.
// RepeatCount is set during decoding and is available on the raw (pre-ApplyDefaults) beatmap,
// so this can be derived without a full conversion pass.
beatmapInfo.MaxSliderRepeats = beatmap.HitObjects
.OfType<IHasRepeats>()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.Select(h => h.RepeatCount)
.DefaultIfEmpty(0)
.Max();

// Slider tick objects are generated as nested hit objects after defaults have been applied.
// This method is called both with raw and playable beatmaps; for raw beatmaps this will
// naturally become 0 and later be backfilled by background processing.
beatmapInfo.MaxSliderTicks = beatmap.HitObjects
.Select(h => h.NestedHitObjects.Count(n => n is ISliderTick))
.DefaultIfEmpty(0)
.Max();
}

/// <summary>
Expand Down
Loading
Loading