Skip to content

Commit a31eadc

Browse files
Add session-level playable beatmap cache + persisted slider pool metadata
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/24ea579f-da4d-4cf3-8782-8be3ff5d6119 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 58d9792 commit a31eadc

13 files changed

Lines changed: 230 additions & 7 deletions

File tree

osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,36 @@ private void load(OsuRulesetConfigManager? config, IBeatmap? beatmap)
141141
RegisterPool<HitCircle, DrawableHitCircle>(20, 100);
142142

143143
// handle edge cases where a beatmap has a slider with many repeats.
144-
int maxRepeatsOnOneSlider = 0;
144+
int maxRepeatsOnOneSlider;
145145
int maxTicksOnOneSlider = 0;
146146

147-
if (osuBeatmap != null)
147+
// Use persisted metadata when available to avoid a full beatmap scan.
148+
// Fall back to scanning the loaded beatmap if metadata has not yet been computed.
149+
int persistedMaxRepeats = beatmap?.BeatmapInfo.MaxSliderRepeats ?? -1;
150+
151+
if (persistedMaxRepeats >= 0)
148152
{
149-
foreach (var slider in osuBeatmap.HitObjects.OfType<Slider>())
153+
maxRepeatsOnOneSlider = persistedMaxRepeats;
154+
155+
// Still scan for tick counts since MaxSliderTicks is not yet persisted.
156+
if (osuBeatmap != null)
157+
{
158+
foreach (var slider in osuBeatmap.HitObjects.OfType<Slider>())
159+
maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType<SliderTick>().Count());
160+
}
161+
}
162+
else
163+
{
164+
// Fallback: scan all sliders when persisted metadata is not available.
165+
maxRepeatsOnOneSlider = 0;
166+
167+
if (osuBeatmap != null)
150168
{
151-
maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount);
152-
maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType<SliderTick>().Count());
169+
foreach (var slider in osuBeatmap.HitObjects.OfType<Slider>())
170+
{
171+
maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount);
172+
maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType<SliderTick>().Count());
173+
}
153174
}
154175
}
155176

osu.Game.Tournament/Models/TournamentBeatmap.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo
2626

2727
public int TotalObjectCount { get; set; }
2828

29+
public int MaxSliderRepeats { get; set; }
30+
2931
public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata();
3032

3133
public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty();

osu.Game/Beatmaps/BeatmapInfo.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ public BeatmapOnlineStatus Status
122122

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

125+
/// <summary>
126+
/// The maximum number of repeats found on a single <see cref="osu.Game.Rulesets.Objects.Types.IHasRepeats"/> hit object in the beatmap.
127+
/// Only relevant for rulesets that have such objects (e.g. osu! sliders, mania hold notes).
128+
/// Defaults to -1 (meaning not-yet-calculated).
129+
/// </summary>
130+
public int MaxSliderRepeats { get; set; } = -1;
131+
125132
/// <summary>
126133
/// Reset any fetched online linking information (and history).
127134
/// </summary>

osu.Game/Beatmaps/BeatmapInfoExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBe
2121
beatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
2222
beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration);
2323
beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count;
24+
25+
// Compute the maximum repeat count for pool sizing purposes.
26+
// RepeatCount is set during decoding and is available on the raw (pre-ApplyDefaults) beatmap,
27+
// so this can be derived without a full conversion pass.
28+
beatmapInfo.MaxSliderRepeats = beatmap.HitObjects
29+
.OfType<IHasRepeats>()
30+
.Select(h => h.RepeatCount)
31+
.DefaultIfEmpty(0)
32+
.Max();
2433
}
2534

2635
/// <summary>

osu.Game/Beatmaps/BeatmapUpdater.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope loo
8181

8282
beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration);
8383
beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count;
84+
beatmapInfo.MaxSliderRepeats = beatmap.HitObjects
85+
.OfType<IHasRepeats>()
86+
.Select(h => h.RepeatCount)
87+
.DefaultIfEmpty(0)
88+
.Max();
8489

8590
// And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
8691
workingBeatmapCache.Invalidate(beatmapInfo);

osu.Game/Beatmaps/IBeatmapInfo.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,12 @@ public interface IBeatmapInfo : IHasOnlineID<int>, IEquatable<IBeatmapInfo>
7777
/// Defaults to -1 (meaning not-yet-calculated).
7878
/// </summary>
7979
int TotalObjectCount { get; }
80+
81+
/// <summary>
82+
/// The maximum number of repeats on a single hit object in the beatmap.
83+
/// Only meaningful for rulesets that have repeating objects (e.g. osu! sliders).
84+
/// Defaults to -1 (meaning not-yet-calculated).
85+
/// </summary>
86+
int MaxSliderRepeats { get; }
8087
}
8188
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Linq;
8+
using osu.Framework.Allocation;
9+
using osu.Framework.Graphics;
10+
using osu.Framework.Logging;
11+
using osu.Game.Rulesets;
12+
using osu.Game.Rulesets.Mods;
13+
14+
namespace osu.Game.Beatmaps
15+
{
16+
/// <summary>
17+
/// A session-level cache for post-conversion playable <see cref="IBeatmap"/> instances.
18+
/// Avoids repeating the expensive conversion + <c>ApplyDefaults</c> pipeline when the same beatmap,
19+
/// ruleset and mod combination is requested again (e.g. quick retry, replay reload, repeated plays).
20+
/// </summary>
21+
/// <remarks>
22+
/// <para>
23+
/// The cache stores one <em>canonical</em> (immutable) copy keyed by
24+
/// <c>(BeatmapInfo.ID, rulesetShortName, orderedModsKey)</c>.
25+
/// Callers receive a shallow <see cref="IBeatmap.Clone"/> so that gameplay state does not
26+
/// bleed across sessions while still reusing the pre-built hit-object graph.
27+
/// </para>
28+
/// <para>
29+
/// All entries belonging to a particular <see cref="BeatmapInfo.ID"/> are evicted whenever
30+
/// <see cref="WorkingBeatmapCache.OnInvalidated"/> fires for that beatmap (e.g. on beatmap
31+
/// update or reimport).
32+
/// </para>
33+
/// </remarks>
34+
public class PlayableBeatmapCache : Component
35+
{
36+
private readonly record struct CacheKey(Guid BeatmapId, string RulesetShortName, string ModsKey);
37+
38+
private readonly Dictionary<CacheKey, IBeatmap> cache = new Dictionary<CacheKey, IBeatmap>();
39+
40+
private WorkingBeatmapCache? workingBeatmapCache;
41+
42+
[BackgroundDependencyLoader]
43+
private void load(IWorkingBeatmapCache beatmapCache)
44+
{
45+
if (beatmapCache is WorkingBeatmapCache concrete)
46+
{
47+
workingBeatmapCache = concrete;
48+
workingBeatmapCache.OnInvalidated += handleInvalidated;
49+
}
50+
}
51+
52+
/// <summary>
53+
/// Try to retrieve a pre-built playable beatmap from the cache.
54+
/// </summary>
55+
/// <param name="beatmapInfo">The beatmap whose playable representation is requested.</param>
56+
/// <param name="ruleset">The ruleset used for conversion.</param>
57+
/// <param name="mods">The mods applied during conversion.</param>
58+
/// <param name="playable">
59+
/// On success, a shallow clone of the cached beatmap; ready for use in a new gameplay session.
60+
/// </param>
61+
/// <returns><c>true</c> if a cached entry was found; <c>false</c> otherwise.</returns>
62+
public bool TryGetPlayableBeatmap(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList<Mod> mods, [NotNullWhen(true)] out IBeatmap? playable)
63+
{
64+
var key = makeKey(beatmapInfo, ruleset, mods);
65+
66+
lock (cache)
67+
{
68+
if (cache.TryGetValue(key, out var cached))
69+
{
70+
playable = cached.Clone();
71+
return true;
72+
}
73+
}
74+
75+
playable = null;
76+
return false;
77+
}
78+
79+
/// <summary>
80+
/// Store a playable beatmap in the cache so subsequent requests can reuse it.
81+
/// </summary>
82+
/// <param name="beatmapInfo">The beatmap whose playable representation is being stored.</param>
83+
/// <param name="ruleset">The ruleset used for conversion.</param>
84+
/// <param name="mods">The mods applied during conversion.</param>
85+
/// <param name="playable">The fully-built playable beatmap to cache.</param>
86+
public void CachePlayableBeatmap(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList<Mod> mods, IBeatmap playable)
87+
{
88+
var key = makeKey(beatmapInfo, ruleset, mods);
89+
90+
lock (cache)
91+
cache[key] = playable;
92+
}
93+
94+
private void handleInvalidated(WorkingBeatmap working)
95+
{
96+
Guid id = working.BeatmapInfo.ID;
97+
98+
lock (cache)
99+
{
100+
int removed = 0;
101+
102+
foreach (var key in cache.Keys.Where(k => k.BeatmapId == id).ToList())
103+
{
104+
cache.Remove(key);
105+
removed++;
106+
}
107+
108+
if (removed > 0)
109+
Logger.Log($"Evicted {removed} playable beatmap cache entr{(removed == 1 ? "y" : "ies")} for {working.BeatmapInfo}");
110+
}
111+
}
112+
113+
private static CacheKey makeKey(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList<Mod> mods)
114+
{
115+
// Build a deterministic key from ordered mod acronyms and their settings hash.
116+
// Mod.GetHashCode() accounts for both the type and any user-adjustable settings.
117+
string modsKey = string.Join(';', mods
118+
.OrderBy(m => m.Acronym)
119+
.Select(m => $"{m.Acronym}:{m.GetHashCode()}"));
120+
121+
return new CacheKey(beatmapInfo.ID, ruleset.ShortName, modsKey);
122+
}
123+
124+
protected override void Dispose(bool isDisposing)
125+
{
126+
base.Dispose(isDisposing);
127+
128+
if (workingBeatmapCache != null)
129+
workingBeatmapCache.OnInvalidated -= handleInvalidated;
130+
}
131+
}
132+
}

osu.Game/Database/BackgroundDataStoreProcessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ private void processBeatmapsWithMissingObjectCounts()
295295

296296
realmAccess.Run(r =>
297297
{
298-
foreach (var b in r.All<BeatmapInfo>().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0))
298+
foreach (var b in r.All<BeatmapInfo>().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0 || b.MaxSliderRepeats < 0))
299299
beatmapIds.Add(b.ID);
300300
});
301301

osu.Game/Database/RealmAccess.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ public class RealmAccess : IDisposable
101101
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
102102
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
103103
/// 51 2025-07-22 Add ScoreInfo.Pauses.
104+
/// 52 2025-??-?? Add MaxSliderRepeats to BeatmapInfo for osu! pool sizing metadata.
104105
/// </summary>
105-
private const int schema_version = 51;
106+
private const int schema_version = 52;
106107

107108
/// <summary>
108109
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@@ -1344,6 +1345,14 @@ void remapKeyBinding(int oldAction, int newAction)
13441345
score.LegacyOnlineID = -1;
13451346

13461347
break;
1348+
1349+
case 52:
1350+
// New MaxSliderRepeats field on BeatmapInfo for osu! pool sizing.
1351+
// Set all existing entries to -1 so BackgroundDataStoreProcessor can backfill them.
1352+
foreach (var beatmap in migration.NewRealm.All<BeatmapInfo>())
1353+
beatmap.MaxSliderRepeats = -1;
1354+
1355+
break;
13471356
}
13481357

13491358
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

osu.Game/Online/API/Requests/Responses/APIBeatmap.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ public class APIBeatmap : IBeatmapInfo, IBeatmapOnlineInfo
5050

5151
public int TotalObjectCount => CircleCount + SliderCount + SpinnerCount;
5252

53+
// Not provided by the online API; callers that need this for pool sizing should fall back to an in-memory scan.
54+
public int MaxSliderRepeats => 0;
55+
5356
[JsonProperty(@"drain")]
5457
public float DrainRate { get; set; }
5558

0 commit comments

Comments
 (0)