1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-13 05:22:54 +08:00

Merge branch 'master' into remove-local-realm-thread-switch-handling

This commit is contained in:
Dean Herbert 2021-10-04 17:17:10 +09:00
commit ae5d1e8ac0
75 changed files with 2309 additions and 1252 deletions

View File

@ -53,6 +53,7 @@ jobs:
diffcalc:
name: Run
runs-on: self-hosted
timeout-minutes: 1440
if: needs.metadata.outputs.continue == 'yes'
needs: metadata
strategy:

View File

@ -51,11 +51,11 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.918.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1004.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
<PackageReference Include="Realm" Version="10.5.0" />
<PackageReference Include="Realm" Version="10.6.0" />
</ItemGroup>
</Project>

View File

@ -4,13 +4,11 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Play;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Mods
@ -122,7 +120,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
private bool checkSomeHit() => Player.ScoreProcessor.JudgedHits >= 4;
private bool objectWithIncreasedVisibilityHasIndex(int index)
=> Player.Mods.Value.OfType<TestOsuModHidden>().Single().FirstObject == Player.ChildrenOfType<GameplayBeatmap>().Single().HitObjects[index];
=> Player.Mods.Value.OfType<TestOsuModHidden>().Single().FirstObject == Player.GameplayState.Beatmap.HitObjects[index];
private class TestOsuModHidden : OsuModHidden
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,3 @@
[General]
Version: latest
HitCircleOverlayAboveNumber: 0

View File

@ -17,6 +17,7 @@ using osu.Framework.Testing.Input;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Skinning;
using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Screens.Play;
@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public class TestSceneGameplayCursor : OsuSkinnableTestScene
{
[Cached]
private GameplayBeatmap gameplayBeatmap;
private GameplayState gameplayState;
private OsuCursorContainer lastContainer;
@ -40,7 +41,8 @@ namespace osu.Game.Rulesets.Osu.Tests
public TestSceneGameplayCursor()
{
gameplayBeatmap = new GameplayBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo));
var ruleset = new OsuRuleset();
gameplayState = new GameplayState(CreateBeatmap(ruleset.RulesetInfo), ruleset, Array.Empty<Mod>());
AddStep("change background colour", () =>
{
@ -57,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Tests
AddSliderStep("circle size", 0f, 10f, 0f, val =>
{
config.SetValue(OsuSetting.AutoCursorSize, true);
gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val;
gameplayState.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize = val;
Scheduler.AddOnce(() => loadContent(false));
});
@ -73,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public void TestSizing(int circleSize, float userScale)
{
AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale));
AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize);
AddStep($"adjust cs to {circleSize}", () => gameplayState.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize);
AddStep("turn on autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, true));
AddStep("load content", () => loadContent());

View File

@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private OsuPlayfield playfield { get; set; }
[Resolved(canBeNull: true)]
private GameplayBeatmap gameplayBeatmap { get; set; }
private GameplayState gameplayState { get; set; }
[BackgroundDependencyLoader]
private void load(ISkinSource skin, OsuColour colours)
@ -75,12 +75,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected override void Update()
{
if (playfield == null || gameplayBeatmap == null) return;
if (playfield == null || gameplayState == null) return;
DrawableHitObject kiaiHitObject = null;
// Check whether currently in a kiai section first. This is only done as an optimisation to avoid enumerating AliveObjects when not necessary.
if (gameplayBeatmap.ControlPointInfo.EffectPointAt(Time.Current).KiaiMode)
if (gameplayState.Beatmap.ControlPointInfo.EffectPointAt(Time.Current).KiaiMode)
kiaiHitObject = playfield.HitObjectContainer.AliveObjects.FirstOrDefault(isTracking);
kiaiSpewer.Active.Value = kiaiHitObject != null;

View File

@ -35,8 +35,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private Drawable hitCircleSprite;
protected Drawable HitCircleOverlay { get; private set; }
protected Container OverlayLayer { get; private set; }
private Drawable hitCircleOverlay;
private SkinnableSpriteText hitCircleText;
private readonly Bindable<Color4> accentColour = new Bindable<Color4>();
@ -78,17 +79,22 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
HitCircleOverlay = new KiaiFlashingSprite
OverlayLayer = new Container
{
Texture = overlayTexture,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
Child = hitCircleOverlay = new KiaiFlashingSprite
{
Texture = overlayTexture,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
};
if (hasNumber)
{
AddInternal(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText
OverlayLayer.Add(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText
{
Font = OsuFont.Numeric.With(size: 40),
UseFullGlyphHeight = false,
@ -102,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
bool overlayAboveNumber = skin.GetConfig<OsuSkinConfiguration, bool>(OsuSkinConfiguration.HitCircleOverlayAboveNumber)?.Value ?? true;
if (overlayAboveNumber)
ChangeInternalChildDepth(HitCircleOverlay, float.MinValue);
OverlayLayer.ChangeChildDepth(hitCircleOverlay, float.MinValue);
accentColour.BindTo(drawableObject.AccentColour);
indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable);
@ -147,8 +153,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
hitCircleSprite.FadeOut(legacy_fade_duration, Easing.Out);
hitCircleSprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out);
HitCircleOverlay.FadeOut(legacy_fade_duration, Easing.Out);
HitCircleOverlay.ScaleTo(1.4f, legacy_fade_duration, Easing.Out);
hitCircleOverlay.FadeOut(legacy_fade_duration, Easing.Out);
hitCircleOverlay.ScaleTo(1.4f, legacy_fade_duration, Easing.Out);
if (hasNumber)
{

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
[Resolved(canBeNull: true)]
private DrawableHitObject drawableHitObject { get; set; }
private Drawable proxiedHitCircleOverlay;
private Drawable proxiedOverlayLayer;
public LegacySliderHeadHitCircle()
: base("sliderstartcircle")
@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected override void LoadComplete()
{
base.LoadComplete();
proxiedHitCircleOverlay = HitCircleOverlay.CreateProxy();
proxiedOverlayLayer = OverlayLayer.CreateProxy();
if (drawableHitObject != null)
{
@ -35,11 +35,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private void onHitObjectApplied(DrawableHitObject drawableObject)
{
Debug.Assert(proxiedHitCircleOverlay.Parent == null);
Debug.Assert(proxiedOverlayLayer.Parent == null);
// see logic in LegacyReverseArrow.
(drawableObject as DrawableSliderHead)?.DrawableSlider
.OverlayElementContainer.Add(proxiedHitCircleOverlay.With(d => d.Depth = float.MinValue));
.OverlayElementContainer.Add(proxiedOverlayLayer.With(d => d.Depth = float.MinValue));
}
protected override void Dispose(bool isDisposing)

View File

@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
}
[Resolved(canBeNull: true)]
private GameplayBeatmap beatmap { get; set; }
private GameplayState state { get; set; }
[Resolved]
private OsuConfigManager config { get; set; }
@ -96,10 +96,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
{
float scale = userCursorScale.Value;
if (autoCursorScale.Value && beatmap != null)
if (autoCursorScale.Value && state != null)
{
// if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier.
scale *= GetScaleForCircleSize(beatmap.BeatmapInfo.BaseDifficulty.CircleSize);
scale *= GetScaleForCircleSize(state.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize);
}
cursorScale.Value = scale;

View File

@ -25,10 +25,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
}
[BackgroundDependencyLoader(true)]
private void load(GameplayBeatmap gameplayBeatmap)
private void load(GameplayState gameplayState)
{
if (gameplayBeatmap != null)
((IBindable<JudgementResult>)LastResult).BindTo(gameplayBeatmap.LastJudgementResult);
if (gameplayState != null)
((IBindable<JudgementResult>)LastResult).BindTo(gameplayState.LastJudgementResult);
}
private bool passing;

View File

@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Taiko.UI
}
[BackgroundDependencyLoader(true)]
private void load(TextureStore textures, GameplayBeatmap gameplayBeatmap)
private void load(TextureStore textures, GameplayState gameplayState)
{
InternalChildren = new[]
{
@ -49,8 +49,8 @@ namespace osu.Game.Rulesets.Taiko.UI
animations[TaikoMascotAnimationState.Fail] = new TaikoMascotAnimation(TaikoMascotAnimationState.Fail),
};
if (gameplayBeatmap != null)
((IBindable<JudgementResult>)LastResult).BindTo(gameplayBeatmap.LastJudgementResult);
if (gameplayState != null)
((IBindable<JudgementResult>)LastResult).BindTo(gameplayState.LastJudgementResult);
}
protected override void LoadComplete()

View File

@ -149,5 +149,32 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType);
}
}
[Test]
public void TestDecodeLoopCount()
{
// all loop sequences in loop-count.osb have a total duration of 2000ms (fade in 0->1000ms, fade out 1000->2000ms).
const double loop_duration = 2000;
var decoder = new LegacyStoryboardDecoder();
using (var resStream = TestResources.OpenResource("loop-count.osb"))
using (var stream = new LineBufferedReader(resStream))
{
var storyboard = decoder.Decode(stream);
StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3);
// stable ensures that any loop command executes at least once, even if the loop count specified in the .osb is zero or negative.
StoryboardSprite zeroTimes = background.Elements.OfType<StoryboardSprite>().Single(s => s.Path == "zero-times.png");
Assert.That(zeroTimes.EndTime, Is.EqualTo(1000 + loop_duration));
StoryboardSprite oneTime = background.Elements.OfType<StoryboardSprite>().Single(s => s.Path == "one-time.png");
Assert.That(oneTime.EndTime, Is.EqualTo(4000 + loop_duration));
StoryboardSprite manyTimes = background.Elements.OfType<StoryboardSprite>().Single(s => s.Path == "many-times.png");
Assert.That(manyTimes.EndTime, Is.EqualTo(9000 + 40 * loop_duration));
}
}
}
}

View File

@ -0,0 +1,64 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
#nullable enable
namespace osu.Game.Tests.Database
{
[TestFixture]
public class GeneralUsageTests : RealmTest
{
/// <summary>
/// Just test the construction of a new database works.
/// </summary>
[Test]
public void TestConstructRealm()
{
RunTestWithRealm((realmFactory, _) => { realmFactory.CreateContext().Refresh(); });
}
[Test]
public void TestBlockOperations()
{
RunTestWithRealm((realmFactory, _) =>
{
using (realmFactory.BlockAllOperations())
{
}
});
}
[Test]
public void TestBlockOperationsWithContention()
{
RunTestWithRealm((realmFactory, _) =>
{
ManualResetEventSlim stopThreadedUsage = new ManualResetEventSlim();
ManualResetEventSlim hasThreadedUsage = new ManualResetEventSlim();
Task.Factory.StartNew(() =>
{
using (realmFactory.CreateContext())
{
hasThreadedUsage.Set();
stopThreadedUsage.Wait();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler);
hasThreadedUsage.Wait();
Assert.Throws<TimeoutException>(() =>
{
using (realmFactory.BlockAllOperations())
{
}
});
stopThreadedUsage.Set();
});
}
}
}

View File

@ -0,0 +1,83 @@
// 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 System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Nito.AsyncEx;
using NUnit.Framework;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Database;
#nullable enable
namespace osu.Game.Tests.Database
{
[TestFixture]
public abstract class RealmTest
{
private static readonly TemporaryNativeStorage storage;
static RealmTest()
{
storage = new TemporaryNativeStorage("realm-test");
storage.DeleteDirectory(string.Empty);
}
protected void RunTestWithRealm(Action<RealmContextFactory, Storage> testAction, [CallerMemberName] string caller = "")
{
AsyncContext.Run(() =>
{
var testStorage = storage.GetStorageForDirectory(caller);
using (var realmFactory = new RealmContextFactory(testStorage, caller))
{
Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
testAction(realmFactory, testStorage);
realmFactory.Dispose();
Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
realmFactory.Compact();
Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}");
}
});
}
protected void RunTestWithRealmAsync(Func<RealmContextFactory, Storage, Task> testAction, [CallerMemberName] string caller = "")
{
AsyncContext.Run(async () =>
{
var testStorage = storage.GetStorageForDirectory(caller);
using (var realmFactory = new RealmContextFactory(testStorage, caller))
{
Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
await testAction(realmFactory, testStorage);
realmFactory.Dispose();
Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
realmFactory.Compact();
Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}");
}
});
}
private static long getFileSize(Storage testStorage, RealmContextFactory realmFactory)
{
try
{
using (var stream = testStorage.GetStream(realmFactory.Filename))
return stream?.Length ?? 0;
}
catch
{
// windows runs may error due to file still being open.
return 0;
}
}
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Tests.Database
storage = new NativeStorage(directory.FullName);
realmContextFactory = new RealmContextFactory(storage);
realmContextFactory = new RealmContextFactory(storage, "test");
keyBindingStore = new RealmKeyBindingStore(realmContextFactory);
}
@ -53,9 +53,9 @@ namespace osu.Game.Tests.Database
private int queryCount(GlobalAction? match = null)
{
using (var usage = realmContextFactory.GetForRead())
using (var realm = realmContextFactory.CreateContext())
{
var results = usage.Realm.All<RealmKeyBinding>();
var results = realm.All<RealmKeyBinding>();
if (match.HasValue)
results = results.Where(k => k.ActionInt == (int)match.Value);
return results.Count();
@ -69,26 +69,24 @@ namespace osu.Game.Tests.Database
keyBindingStore.Register(testContainer, Enumerable.Empty<RulesetInfo>());
using (var primaryUsage = realmContextFactory.GetForRead())
using (var primaryRealm = realmContextFactory.CreateContext())
{
var backBinding = primaryUsage.Realm.All<RealmKeyBinding>().Single(k => k.ActionInt == (int)GlobalAction.Back);
var backBinding = primaryRealm.All<RealmKeyBinding>().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape }));
var tsr = ThreadSafeReference.Create(backBinding);
using (var usage = realmContextFactory.GetForWrite())
using (var threadedContext = realmContextFactory.CreateContext())
{
var binding = usage.Realm.ResolveReference(tsr);
binding.KeyCombination = new KeyCombination(InputKey.BackSpace);
usage.Commit();
var binding = threadedContext.ResolveReference(tsr);
threadedContext.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace));
}
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
// check still correct after re-query.
backBinding = primaryUsage.Realm.All<RealmKeyBinding>().Single(k => k.ActionInt == (int)GlobalAction.Back);
backBinding = primaryRealm.All<RealmKeyBinding>().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
}
}

View File

@ -158,18 +158,47 @@ namespace osu.Game.Tests.Online
public Task<BeatmapSetInfo> CurrentImportTask { get; private set; }
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
=> new TestDownloadRequest(set);
public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
: base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, performOnlineLookups)
public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null)
: base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap)
{
}
public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
{
await AllowImport.Task.ConfigureAwait(false);
return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host);
}
protected override BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
{
return new TestBeatmapModelDownloader(modelManager, api, host);
}
internal class TestBeatmapModelDownloader : BeatmapModelDownloader
{
public TestBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost)
: base(modelManager, apiProvider, gameHost)
{
}
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
=> new TestDownloadRequest(set);
}
internal class TestBeatmapModelManager : BeatmapModelManager
{
private readonly TestBeatmapManager testBeatmapManager;
public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
: base(storage, databaseContextFactory, rulesetStore, gameHost)
{
this.testBeatmapManager = testBeatmapManager;
}
public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
await testBeatmapManager.AllowImport.Task.ConfigureAwait(false);
return await (testBeatmapManager.CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,15 @@
osu file format v14
[Events]
Sprite,Background,TopCentre,"zero-times.png",320,240
L,1000,0
F,0,0,1000,0,1
F,0,1000,2000,1,0
Sprite,Background,TopCentre,"one-time.png",320,240
L,4000,1
F,0,0,1000,0,1
F,0,1000,2000,1,0
Sprite,Background,TopCentre,"many-times.png",320,240
L,9000,40
F,0,0,1000,0,1
F,0,1000,2000,1,0

View File

@ -1,6 +1,7 @@
// 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 System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@ -17,6 +18,8 @@ using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
@ -38,7 +41,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private TestReplayRecorder recorder;
[Cached]
private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap());
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
[SetUp]
public void SetUp() => Schedule(() =>
@ -57,7 +60,7 @@ namespace osu.Game.Tests.Visual.Gameplay
Recorder = recorder = new TestReplayRecorder(new Score
{
Replay = replay,
ScoreInfo = { Beatmap = gameplayBeatmap.BeatmapInfo }
ScoreInfo = { Beatmap = gameplayState.Beatmap.BeatmapInfo }
})
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),

View File

@ -1,6 +1,7 @@
// 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 System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -13,6 +14,8 @@ using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
@ -30,7 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private readonly TestRulesetInputManager recordingManager;
[Cached]
private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap());
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
public TestSceneReplayRecording()
{
@ -48,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay
Recorder = new TestReplayRecorder(new Score
{
Replay = replay,
ScoreInfo = { Beatmap = gameplayBeatmap.BeatmapInfo }
ScoreInfo = { Beatmap = gameplayState.Beatmap.BeatmapInfo }
})
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos)

View File

@ -25,6 +25,8 @@ using osu.Game.Online.Spectator;
using osu.Game.Replays;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.UI;
@ -62,7 +64,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private SpectatorClient spectatorClient { get; set; }
[Cached]
private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap());
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
[SetUp]
public void SetUp() => Schedule(() =>

View File

@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings.Sections.Input;
using osuTK.Input;
@ -230,6 +231,22 @@ namespace osu.Game.Tests.Visual.Settings
AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType<KeyBindingRow.KeyButton>().First().IsBinding);
}
[Test]
public void TestFilteringHidesResetSectionButtons()
{
SearchTextBox searchTextBox = null;
AddStep("add any search term", () =>
{
searchTextBox = panel.ChildrenOfType<SearchTextBox>().Single();
searchTextBox.Current.Value = "chat";
});
AddUntilStep("all reset section bindings buttons hidden", () => panel.ChildrenOfType<ResetButton>().All(button => button.Alpha == 0));
AddStep("clear search term", () => searchTextBox.Current.Value = string.Empty);
AddUntilStep("all reset section bindings buttons shown", () => panel.ChildrenOfType<ResetButton>().All(button => button.Alpha == 1));
}
private void checkBinding(string name, string keyName)
{
AddAssert($"Check {name} is bound to {keyName}", () =>

View File

@ -0,0 +1,77 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneOsuFont : OsuTestScene
{
private OsuSpriteText spriteText;
private readonly BindableBool useAlternates = new BindableBool();
private readonly Bindable<FontWeight> weight = new Bindable<FontWeight>(FontWeight.Regular);
[BackgroundDependencyLoader]
private void load()
{
Child = spriteText = new OsuSpriteText
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AllowMultiline = true,
};
}
protected override void LoadComplete()
{
base.LoadComplete();
useAlternates.BindValueChanged(_ => updateFont());
weight.BindValueChanged(_ => updateFont(), true);
}
private void updateFont()
{
FontUsage usage = useAlternates.Value ? OsuFont.TorusAlternate : OsuFont.Torus;
spriteText.Font = usage.With(size: 40, weight: weight.Value);
}
[Test]
public void TestTorusAlternates()
{
AddStep("set all ASCII letters", () => spriteText.Text = @"ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz");
AddStep("set all alternates", () => spriteText.Text = @"A Á Ă Â Ä À Ā Ą Å Ã
Æ B D Ð Ď Đ E É Ě Ê
Ë Ė È Ē Ę F G Ğ Ģ Ġ
H I Í Î Ï İ Ì Ī Į K
Ķ O Œ P Þ Q R Ŕ Ř Ŗ
T Ŧ Ť Ţ Ț V W Ŵ
X Y Ý Ŷ Ÿ a á ă
â ä à ā ą å ã æ b d
ď đ e é ě ê ë ė è ē
ę f g ğ ģ ġ k ķ m n
ń ň ņ ŋ ñ o œ p þ q
t ŧ ť ţ ț u ú û ü ù
ű ū ų ů w ŵ x
y ý ŷ ÿ ");
AddToggleStep("toggle alternates", alternates => useAlternates.Value = alternates);
addSetWeightStep(FontWeight.Light);
addSetWeightStep(FontWeight.Regular);
addSetWeightStep(FontWeight.SemiBold);
addSetWeightStep(FontWeight.Bold);
void addSetWeightStep(FontWeight newWeight) => AddStep($"set weight {newWeight}", () => weight.Value = newWeight);
}
}
}

View File

@ -4,6 +4,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -6,111 +6,67 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Lists;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Skinning;
using osu.Game.Users;
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
/// Handles general operations related to global beatmap management.
/// </summary>
[ExcludeFromDynamicCompile]
public partial class BeatmapManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>, IDisposable, IBeatmapResourceProvider
public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelManager<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable
{
/// <summary>
/// Fired when a single difficulty has been hidden.
/// </summary>
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
private readonly BeatmapModelManager beatmapModelManager;
private readonly BeatmapModelDownloader beatmapModelDownloader;
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
/// <summary>
/// Fired when a single difficulty has been restored.
/// </summary>
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
/// </summary>
public readonly WorkingBeatmap DefaultBeatmap;
public override IEnumerable<string> HandledExtensions => new[] { ".osz" };
protected override string[] HashableFileTypes => new[] { ".osu" };
protected override string ImportFromStablePath => ".";
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
private readonly RulesetStore rulesets;
private readonly BeatmapStore beatmaps;
private readonly AudioManager audioManager;
private readonly IResourceStore<byte[]> resources;
private readonly LargeTextureStore largeTextureStore;
private readonly ITrackStore trackStore;
[CanBeNull]
private readonly GameHost host;
[CanBeNull]
private readonly BeatmapOnlineLookupQueue onlineLookupQueue;
private readonly WorkingBeatmapCache workingBeatmapCache;
private readonly BeatmapOnlineLookupQueue onlineBetamapLookupQueue;
public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null,
WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
: base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
{
this.rulesets = rulesets;
this.audioManager = audioManager;
this.resources = resources;
this.host = host;
beatmapModelManager = CreateBeatmapModelManager(storage, contextFactory, rulesets, api, host);
beatmapModelDownloader = CreateBeatmapModelDownloader(beatmapModelManager, api, host);
workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, resources, new FileStore(contextFactory, storage).Store, defaultBeatmap, host);
DefaultBeatmap = defaultBeatmap;
beatmaps = (BeatmapStore)ModelStore;
beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
beatmaps.ItemRemoved += removeWorkingCache;
beatmaps.ItemUpdated += removeWorkingCache;
workingBeatmapCache.BeatmapManager = beatmapModelManager;
if (performOnlineLookups)
onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store));
trackStore = audioManager.GetTrackStore(Files.Store);
{
onlineBetamapLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
beatmapModelManager.OnlineLookupQueue = onlineBetamapLookupQueue;
}
}
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
{
return new BeatmapModelDownloader(modelManager, api, host);
}
protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host) =>
new WorkingBeatmapCache(audioManager, resources, storage, defaultBeatmap, host);
protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) =>
new BeatmapModelManager(storage, contextFactory, rulesets, host);
/// <summary>
/// Create a new <see cref="WorkingBeatmap"/>.
/// </summary>
public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
{
var metadata = new BeatmapMetadata
@ -134,112 +90,21 @@ namespace osu.Game.Beatmaps
}
};
var working = Import(set).Result;
var working = beatmapModelManager.Import(set).Result;
return GetWorkingBeatmap(working.Beatmaps.First());
}
protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
{
if (archive != null)
beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
{
// remove metadata from difficulties where it matches the set
if (beatmapSet.Metadata.Equals(b.Metadata))
b.Metadata = null;
b.BeatmapSet = beatmapSet;
}
validateOnlineIds(beatmapSet);
bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
if (onlineLookupQueue != null)
await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
{
if (beatmapSet.OnlineBeatmapSetID != null)
{
beatmapSet.OnlineBeatmapSetID = null;
LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
}
}
}
protected override void PreImport(BeatmapSetInfo beatmapSet)
{
if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
// check if a set already exists with the same online id, delete if it does.
if (beatmapSet.OnlineBeatmapSetID != null)
{
var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
if (existingOnlineId != null)
{
Delete(existingOnlineId);
// in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
existingOnlineId.OnlineBeatmapSetID = null;
foreach (var b in existingOnlineId.Beatmaps)
b.OnlineBeatmapID = null;
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
}
}
}
private void validateOnlineIds(BeatmapSetInfo beatmapSet)
{
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
// ensure all IDs are unique
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
{
LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
resetIds();
return;
}
// find any existing beatmaps in the database that have matching online ids
var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
if (existingBeatmaps.Count > 0)
{
// reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
// we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
var existing = CheckForExisting(beatmapSet);
if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
{
LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
resetIds();
}
}
void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
}
protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable<BeatmapSetInfo> items)
=> base.CheckLocalAvailability(model, items)
|| (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
#region Delegation to BeatmapModelManager (methods which previously existed locally).
/// <summary>
/// Delete a beatmap difficulty.
/// Fired when a single difficulty has been hidden.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to hide.</param>
public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapModelManager.BeatmapHidden;
/// <summary>
/// Restore a beatmap difficulty.
/// Fired when a single difficulty has been restored.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to restore.</param>
public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapModelManager.BeatmapRestored;
/// <summary>
/// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
@ -247,109 +112,13 @@ namespace osu.Game.Beatmaps
/// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
/// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
/// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
{
var setInfo = info.BeatmapSet;
using (var stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
using (ContextFactory.GetForWrite())
{
var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
// grab the original file (or create a new one if not found).
var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
// metadata may have changed; update the path with the standard format.
beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
// update existing or populate new file's filename.
fileInfo.Filename = beatmapInfo.Path;
stream.Seek(0, SeekOrigin.Begin);
ReplaceFile(setInfo, fileInfo, stream);
}
}
removeWorkingCache(info);
}
private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
/// <summary>
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
/// </summary>
/// <param name="beatmapInfo">The beatmap to lookup.</param>
/// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
{
// if there are no files, presume the full beatmap info has not yet been fetched from the database.
if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
{
int lookupId = beatmapInfo.ID;
beatmapInfo = QueryBeatmap(b => b.ID == lookupId);
}
if (beatmapInfo?.BeatmapSet == null)
return DefaultBeatmap;
lock (workingCache)
{
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
if (working != null)
return working;
beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
// best effort; may be higher than expected.
GlobalStatistics.Get<int>(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
return working;
}
}
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanSkipImport(existing, import))
return false;
return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
}
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanReuseExisting(existing, import))
return false;
var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
// force re-import if we are not in a sane state.
return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
}
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin);
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSets(includes, includeProtected);
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
@ -357,34 +126,7 @@ namespace osu.Game.Beatmaps
/// <param name="includes">The level of detail to include in the returned objects.</param>
/// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
{
IQueryable<BeatmapSetInfo> queryable;
switch (includes)
{
case IncludedDetails.Minimal:
queryable = beatmaps.BeatmapSetsOverview;
break;
case IncludedDetails.AllButRuleset:
queryable = beatmaps.BeatmapSetsWithoutRuleset;
break;
case IncludedDetails.AllButFiles:
queryable = beatmaps.BeatmapSetsWithoutFiles;
break;
default:
queryable = beatmaps.ConsumableItems;
break;
}
// AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
// clause which causes queries to take 5-10x longer.
// TODO: remove if upgrading to EF core 3.x.
return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
}
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSetsEnumerable(includes, includeProtected);
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
@ -392,207 +134,204 @@ namespace osu.Game.Beatmaps
/// <param name="query">The query.</param>
/// <param name="includes">The level of detail to include in the returned objects.</param>
/// <returns>Results from the provided query.</returns>
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
{
IQueryable<BeatmapSetInfo> queryable;
switch (includes)
{
case IncludedDetails.Minimal:
queryable = beatmaps.BeatmapSetsOverview;
break;
case IncludedDetails.AllButRuleset:
queryable = beatmaps.BeatmapSetsWithoutRuleset;
break;
case IncludedDetails.AllButFiles:
queryable = beatmaps.BeatmapSetsWithoutFiles;
break;
default:
queryable = beatmaps.ConsumableItems;
break;
}
return queryable.AsNoTracking().Where(query);
}
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All) => beatmapModelManager.QueryBeatmapSets(query, includes);
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmapModelManager.QueryBeatmapSet(query);
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>Results from the provided query.</returns>
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmaps(query);
protected override string HumanisedModelName => "beatmap";
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmap(query);
protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
/// </summary>
public WorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap;
/// <summary>
/// Fired when a notification should be presented to the user.
/// </summary>
public Action<Notification> PostNotification
{
// let's make sure there are actually .osu files to import.
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(mapName))
set
{
Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
return null;
beatmapModelManager.PostNotification = value;
beatmapModelDownloader.PostNotification = value;
}
Beatmap beatmap;
using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
return new BeatmapSetInfo
{
OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
Beatmaps = new List<BeatmapInfo>(),
Metadata = beatmap.Metadata,
DateAdded = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
/// Fired when the user requests to view the resulting import.
/// </summary>
private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
{
var beatmapInfos = new List<BeatmapInfo>();
public Action<IEnumerable<BeatmapSetInfo>> PresentImport { set => beatmapModelManager.PresentImport = value; }
foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
{
using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
using (var ms = new MemoryStream()) // we need a memory stream so we can seek
using (var sr = new LineBufferedReader(ms))
{
raw.CopyTo(ms);
ms.Position = 0;
/// <summary>
/// Delete a beatmap difficulty.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to hide.</param>
public void Hide(BeatmapInfo beatmap) => beatmapModelManager.Hide(beatmap);
var decoder = Decoder.GetDecoder<Beatmap>(sr);
IBeatmap beatmap = decoder.Decode(sr);
string hash = ms.ComputeSHA2Hash();
if (beatmapInfos.Any(b => b.Hash == hash))
continue;
beatmap.BeatmapInfo.Path = file.Filename;
beatmap.BeatmapInfo.Hash = hash;
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
beatmap.BeatmapInfo.Ruleset = ruleset;
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
beatmap.BeatmapInfo.Length = calculateLength(beatmap);
beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
beatmapInfos.Add(beatmap.BeatmapInfo);
}
}
return beatmapInfos;
}
private double calculateLength(IBeatmap b)
{
if (!b.HitObjects.Any())
return 0;
var lastObject = b.HitObjects.Last();
//TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
double endTime = lastObject.GetEndTime();
double startTime = b.HitObjects.First().StartTime;
return endTime - startTime;
}
private void removeWorkingCache(BeatmapSetInfo info)
{
if (info.Beatmaps == null) return;
foreach (var b in info.Beatmaps)
removeWorkingCache(b);
}
private void removeWorkingCache(BeatmapInfo info)
{
lock (workingCache)
{
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
if (working != null)
workingCache.Remove(working);
}
}
public void Dispose()
{
onlineLookupQueue?.Dispose();
}
#region IResourceStorageProvider
TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
AudioManager IStorageResourceProvider.AudioManager => audioManager;
IResourceStore<byte[]> IStorageResourceProvider.Files => Files.Store;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
/// <summary>
/// Restore a beatmap difficulty.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to restore.</param>
public void Restore(BeatmapInfo beatmap) => beatmapModelManager.Restore(beatmap);
#endregion
/// <summary>
/// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
/// </summary>
private class DummyConversionBeatmap : WorkingBeatmap
#region Implementation of IModelManager<BeatmapSetInfo>
public bool IsAvailableLocally(BeatmapSetInfo model)
{
private readonly IBeatmap beatmap;
public DummyConversionBeatmap(IBeatmap beatmap)
: base(beatmap.BeatmapInfo, null)
{
this.beatmap = beatmap;
}
protected override IBeatmap GetBeatmap() => beatmap;
protected override Texture GetBackground() => null;
protected override Track GetBeatmapTrack() => null;
protected internal override ISkin GetSkin() => null;
public override Stream GetStream(string storagePath) => null;
return beatmapModelManager.IsAvailableLocally(model);
}
}
/// <summary>
/// The level of detail to include in database results.
/// </summary>
public enum IncludedDetails
{
/// <summary>
/// Only include beatmap difficulties and set level metadata.
/// </summary>
Minimal,
public IBindable<WeakReference<BeatmapSetInfo>> ItemUpdated => beatmapModelManager.ItemUpdated;
/// <summary>
/// Include all difficulties, rulesets, difficulty metadata but no files.
/// </summary>
AllButFiles,
public IBindable<WeakReference<BeatmapSetInfo>> ItemRemoved => beatmapModelManager.ItemRemoved;
/// <summary>
/// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
/// </summary>
AllButRuleset,
public Task ImportFromStableAsync(StableStorage stableStorage)
{
return beatmapModelManager.ImportFromStableAsync(stableStorage);
}
/// <summary>
/// Include everything.
/// </summary>
All
public void Export(BeatmapSetInfo item)
{
beatmapModelManager.Export(item);
}
public void ExportModelTo(BeatmapSetInfo model, Stream outputStream)
{
beatmapModelManager.ExportModelTo(model, outputStream);
}
public void Update(BeatmapSetInfo item)
{
beatmapModelManager.Update(item);
}
public bool Delete(BeatmapSetInfo item)
{
return beatmapModelManager.Delete(item);
}
public void Delete(List<BeatmapSetInfo> items, bool silent = false)
{
beatmapModelManager.Delete(items, silent);
}
public void Undelete(List<BeatmapSetInfo> items, bool silent = false)
{
beatmapModelManager.Undelete(items, silent);
}
public void Undelete(BeatmapSetInfo item)
{
beatmapModelManager.Undelete(item);
}
#endregion
#region Implementation of IModelDownloader<BeatmapSetInfo>
public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadBegan => beatmapModelDownloader.DownloadBegan;
public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadFailed => beatmapModelDownloader.DownloadFailed;
public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false)
{
return beatmapModelDownloader.Download(model, minimiseDownloadSize);
}
public ArchiveDownloadRequest<BeatmapSetInfo> GetExistingDownload(BeatmapSetInfo model)
{
return beatmapModelDownloader.GetExistingDownload(model);
}
#endregion
#region Implementation of ICanAcceptFiles
public Task Import(params string[] paths)
{
return beatmapModelManager.Import(paths);
}
public Task Import(params ImportTask[] tasks)
{
return beatmapModelManager.Import(tasks);
}
public Task<IEnumerable<BeatmapSetInfo>> Import(ProgressNotification notification, params ImportTask[] tasks)
{
return beatmapModelManager.Import(notification, tasks);
}
public Task<BeatmapSetInfo> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return beatmapModelManager.Import(task, lowPriority, cancellationToken);
}
public Task<BeatmapSetInfo> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return beatmapModelManager.Import(archive, lowPriority, cancellationToken);
}
public Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken);
}
public IEnumerable<string> HandledExtensions => beatmapModelManager.HandledExtensions;
#endregion
#region Implementation of IWorkingBeatmapCache
public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo importedBeatmap) => workingBeatmapCache.GetWorkingBeatmap(importedBeatmap);
#endregion
#region Implementation of IModelFileManager<in BeatmapSetInfo,in BeatmapSetFileInfo>
public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents, string filename = null)
{
beatmapModelManager.ReplaceFile(model, file, contents, filename);
}
public void DeleteFile(BeatmapSetInfo model, BeatmapSetFileInfo file)
{
beatmapModelManager.DeleteFile(model, file);
}
public void AddFile(BeatmapSetInfo model, Stream contents, string filename)
{
beatmapModelManager.AddFile(model, contents, filename);
}
#endregion
#region Implementation of IDisposable
public void Dispose()
{
onlineBetamapLookupQueue?.Dispose();
}
#endregion
}
}

View File

@ -1,215 +0,0 @@
// 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 System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using osu.Framework.Development;
using osu.Framework.IO.Network;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using SharpCompress.Compressors;
using SharpCompress.Compressors.BZip2;
namespace osu.Game.Beatmaps
{
public partial class BeatmapManager
{
[ExcludeFromDynamicCompile]
private class BeatmapOnlineLookupQueue : IDisposable
{
private readonly IAPIProvider api;
private readonly Storage storage;
private const int update_queue_request_concurrency = 4;
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
private FileWebRequest cacheDownloadRequest;
private const string cache_database_name = "online.db";
public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
{
this.api = api;
this.storage = storage;
// avoid downloading / using cache for unit tests.
if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
prepareLocalCache();
}
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
{
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
}
// todo: expose this when we need to do individual difficulty lookups.
protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
=> Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
{
if (checkLocalCache(set, beatmap))
return;
if (api?.State.Value != APIState.Online)
return;
var req = new GetBeatmapRequest(beatmap);
req.Failure += fail;
try
{
// intentionally blocking to limit web request concurrency
api.Perform(req);
var res = req.Result;
if (res != null)
{
beatmap.Status = res.Status;
beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
if (beatmap.Metadata != null)
beatmap.Metadata.AuthorID = res.AuthorID;
if (beatmap.BeatmapSet.Metadata != null)
beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
}
}
catch (Exception e)
{
fail(e);
}
void fail(Exception e)
{
beatmap.OnlineBeatmapID = null;
LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
}
}
private void prepareLocalCache()
{
string cacheFilePath = storage.GetFullPath(cache_database_name);
string compressedCacheFilePath = $"{cacheFilePath}.bz2";
cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
cacheDownloadRequest.Failed += ex =>
{
File.Delete(compressedCacheFilePath);
File.Delete(cacheFilePath);
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
};
cacheDownloadRequest.Finished += () =>
{
try
{
using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
using (var outStream = File.OpenWrite(cacheFilePath))
using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
bz2.CopyTo(outStream);
// set to null on completion to allow lookups to begin using the new source
cacheDownloadRequest = null;
}
catch (Exception ex)
{
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
File.Delete(cacheFilePath);
}
finally
{
File.Delete(compressedCacheFilePath);
}
};
cacheDownloadRequest.PerformAsync();
}
private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
{
// download is in progress (or was, and failed).
if (cacheDownloadRequest != null)
return false;
// database is unavailable.
if (!storage.Exists(cache_database_name))
return false;
if (string.IsNullOrEmpty(beatmap.MD5Hash)
&& string.IsNullOrEmpty(beatmap.Path)
&& beatmap.OnlineBeatmapID == null)
return false;
try
{
using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage)))
{
db.Open();
using (var cmd = db.CreateCommand())
{
cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
beatmap.Status = status;
beatmap.BeatmapSet.Status = status;
beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
beatmap.OnlineBeatmapID = reader.GetInt32(1);
if (beatmap.Metadata != null)
beatmap.Metadata.AuthorID = reader.GetInt32(3);
if (beatmap.BeatmapSet.Metadata != null)
beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
LogForModel(set, $"Cached local retrieval for {beatmap}.");
return true;
}
}
}
}
}
catch (Exception ex)
{
LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
}
return false;
}
public void Dispose()
{
cacheDownloadRequest?.Dispose();
updateScheduler?.Dispose();
}
}
}
}

View File

@ -0,0 +1,21 @@
// 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.Framework.Platform;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
namespace osu.Game.Beatmaps
{
public class BeatmapModelDownloader : ModelDownloader<BeatmapSetInfo>
{
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
public BeatmapModelDownloader(BeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null)
: base(beatmapModelManager, api, host)
{
}
}
}

View File

@ -0,0 +1,473 @@
// 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 System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Textures;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Skinning;
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Handles ef-core storage of beatmaps.
/// </summary>
[ExcludeFromDynamicCompile]
public class BeatmapModelManager : ArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>
{
/// <summary>
/// Fired when a single difficulty has been hidden.
/// </summary>
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
/// <summary>
/// Fired when a single difficulty has been restored.
/// </summary>
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
/// <summary>
/// An online lookup queue component which handles populating online beatmap metadata.
/// </summary>
public BeatmapOnlineLookupQueue OnlineLookupQueue { private get; set; }
/// <summary>
/// The game working beatmap cache, used to invalidate entries on changes.
/// </summary>
public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
public override IEnumerable<string> HandledExtensions => new[] { ".osz" };
protected override string[] HashableFileTypes => new[] { ".osu" };
protected override string ImportFromStablePath => ".";
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
private readonly BeatmapStore beatmaps;
private readonly RulesetStore rulesets;
public BeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, GameHost host = null)
: base(storage, contextFactory, new BeatmapStore(contextFactory), host)
{
this.rulesets = rulesets;
beatmaps = (BeatmapStore)ModelStore;
beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
}
protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
{
if (archive != null)
beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
{
// remove metadata from difficulties where it matches the set
if (beatmapSet.Metadata.Equals(b.Metadata))
b.Metadata = null;
b.BeatmapSet = beatmapSet;
}
validateOnlineIds(beatmapSet);
bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
if (OnlineLookupQueue != null)
await OnlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
{
if (beatmapSet.OnlineBeatmapSetID != null)
{
beatmapSet.OnlineBeatmapSetID = null;
LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
}
}
}
protected override void PreImport(BeatmapSetInfo beatmapSet)
{
if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
// check if a set already exists with the same online id, delete if it does.
if (beatmapSet.OnlineBeatmapSetID != null)
{
var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
if (existingOnlineId != null)
{
Delete(existingOnlineId);
// in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
existingOnlineId.OnlineBeatmapSetID = null;
foreach (var b in existingOnlineId.Beatmaps)
b.OnlineBeatmapID = null;
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
}
}
}
private void validateOnlineIds(BeatmapSetInfo beatmapSet)
{
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
// ensure all IDs are unique
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
{
LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
resetIds();
return;
}
// find any existing beatmaps in the database that have matching online ids
var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
if (existingBeatmaps.Count > 0)
{
// reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
// we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
var existing = CheckForExisting(beatmapSet);
if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
{
LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
resetIds();
}
}
void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
}
/// <summary>
/// Delete a beatmap difficulty.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to hide.</param>
public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
/// <summary>
/// Restore a beatmap difficulty.
/// </summary>
/// <param name="beatmap">The beatmap difficulty to restore.</param>
public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
/// <summary>
/// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
/// </summary>
/// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
/// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
/// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
{
var setInfo = info.BeatmapSet;
using (var stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
using (ContextFactory.GetForWrite())
{
var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
// grab the original file (or create a new one if not found).
var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
// metadata may have changed; update the path with the standard format.
beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
// update existing or populate new file's filename.
fileInfo.Filename = beatmapInfo.Path;
stream.Seek(0, SeekOrigin.Begin);
ReplaceFile(setInfo, fileInfo, stream);
}
}
WorkingBeatmapCache?.Invalidate(info);
}
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanSkipImport(existing, import))
return false;
return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
}
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanReuseExisting(existing, import))
return false;
var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
// force re-import if we are not in a sane state.
return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
}
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
/// </summary>
/// <param name="includes">The level of detail to include in the returned objects.</param>
/// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
{
IQueryable<BeatmapSetInfo> queryable;
switch (includes)
{
case IncludedDetails.Minimal:
queryable = beatmaps.BeatmapSetsOverview;
break;
case IncludedDetails.AllButRuleset:
queryable = beatmaps.BeatmapSetsWithoutRuleset;
break;
case IncludedDetails.AllButFiles:
queryable = beatmaps.BeatmapSetsWithoutFiles;
break;
default:
queryable = beatmaps.ConsumableItems;
break;
}
// AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
// clause which causes queries to take 5-10x longer.
// TODO: remove if upgrading to EF core 3.x.
return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
}
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <param name="includes">The level of detail to include in the returned objects.</param>
/// <returns>Results from the provided query.</returns>
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
{
IQueryable<BeatmapSetInfo> queryable;
switch (includes)
{
case IncludedDetails.Minimal:
queryable = beatmaps.BeatmapSetsOverview;
break;
case IncludedDetails.AllButRuleset:
queryable = beatmaps.BeatmapSetsWithoutRuleset;
break;
case IncludedDetails.AllButFiles:
queryable = beatmaps.BeatmapSetsWithoutFiles;
break;
default:
queryable = beatmaps.ConsumableItems;
break;
}
return queryable.AsNoTracking().Where(query);
}
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>Results from the provided query.</returns>
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
public override string HumanisedModelName => "beatmap";
protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable<BeatmapSetInfo> items)
=> base.CheckLocalAvailability(model, items)
|| (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
{
// let's make sure there are actually .osu files to import.
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(mapName))
{
Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
return null;
}
Beatmap beatmap;
using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
return new BeatmapSetInfo
{
OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
Beatmaps = new List<BeatmapInfo>(),
Metadata = beatmap.Metadata,
DateAdded = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
/// </summary>
private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
{
var beatmapInfos = new List<BeatmapInfo>();
foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
{
using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
using (var ms = new MemoryStream()) // we need a memory stream so we can seek
using (var sr = new LineBufferedReader(ms))
{
raw.CopyTo(ms);
ms.Position = 0;
var decoder = Decoder.GetDecoder<Beatmap>(sr);
IBeatmap beatmap = decoder.Decode(sr);
string hash = ms.ComputeSHA2Hash();
if (beatmapInfos.Any(b => b.Hash == hash))
continue;
beatmap.BeatmapInfo.Path = file.Filename;
beatmap.BeatmapInfo.Hash = hash;
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
beatmap.BeatmapInfo.Ruleset = ruleset;
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
beatmap.BeatmapInfo.Length = calculateLength(beatmap);
beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
beatmapInfos.Add(beatmap.BeatmapInfo);
}
}
return beatmapInfos;
}
private double calculateLength(IBeatmap b)
{
if (!b.HitObjects.Any())
return 0;
var lastObject = b.HitObjects.Last();
//TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
double endTime = lastObject.GetEndTime();
double startTime = b.HitObjects.First().StartTime;
return endTime - startTime;
}
/// <summary>
/// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
/// </summary>
private class DummyConversionBeatmap : WorkingBeatmap
{
private readonly IBeatmap beatmap;
public DummyConversionBeatmap(IBeatmap beatmap)
: base(beatmap.BeatmapInfo, null)
{
this.beatmap = beatmap;
}
protected override IBeatmap GetBeatmap() => beatmap;
protected override Texture GetBackground() => null;
protected override Track GetBeatmapTrack() => null;
protected internal override ISkin GetSkin() => null;
public override Stream GetStream(string storagePath) => null;
}
}
/// <summary>
/// The level of detail to include in database results.
/// </summary>
public enum IncludedDetails
{
/// <summary>
/// Only include beatmap difficulties and set level metadata.
/// </summary>
Minimal,
/// <summary>
/// Include all difficulties, rulesets, difficulty metadata but no files.
/// </summary>
AllButFiles,
/// <summary>
/// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
/// </summary>
AllButRuleset,
/// <summary>
/// Include everything.
/// </summary>
All
}
}

View File

@ -0,0 +1,222 @@
// 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 System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using osu.Framework.Development;
using osu.Framework.IO.Network;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using SharpCompress.Compressors;
using SharpCompress.Compressors.BZip2;
namespace osu.Game.Beatmaps
{
/// <summary>
/// A component which handles population of online IDs for beatmaps using a two part lookup procedure.
/// </summary>
/// <remarks>
/// On creating the component, a copy of a database containing metadata for a large subset of beatmaps (stored to <see cref="cache_database_name"/>) will be downloaded if not already present locally.
/// This will always be checked before doing a second online query to get required metadata.
/// </remarks>
[ExcludeFromDynamicCompile]
public class BeatmapOnlineLookupQueue : IDisposable
{
private readonly IAPIProvider api;
private readonly Storage storage;
private const int update_queue_request_concurrency = 4;
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
private FileWebRequest cacheDownloadRequest;
private const string cache_database_name = "online.db";
public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
{
this.api = api;
this.storage = storage;
// avoid downloading / using cache for unit tests.
if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
prepareLocalCache();
}
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
{
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
}
// todo: expose this when we need to do individual difficulty lookups.
protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
=> Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
{
if (checkLocalCache(set, beatmap))
return;
if (api?.State.Value != APIState.Online)
return;
var req = new GetBeatmapRequest(beatmap);
req.Failure += fail;
try
{
// intentionally blocking to limit web request concurrency
api.Perform(req);
var res = req.Result;
if (res != null)
{
beatmap.Status = res.Status;
beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
if (beatmap.Metadata != null)
beatmap.Metadata.AuthorID = res.AuthorID;
if (beatmap.BeatmapSet.Metadata != null)
beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
logForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
}
}
catch (Exception e)
{
fail(e);
}
void fail(Exception e)
{
beatmap.OnlineBeatmapID = null;
logForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
}
}
private void prepareLocalCache()
{
string cacheFilePath = storage.GetFullPath(cache_database_name);
string compressedCacheFilePath = $"{cacheFilePath}.bz2";
cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
cacheDownloadRequest.Failed += ex =>
{
File.Delete(compressedCacheFilePath);
File.Delete(cacheFilePath);
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
};
cacheDownloadRequest.Finished += () =>
{
try
{
using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
using (var outStream = File.OpenWrite(cacheFilePath))
using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
bz2.CopyTo(outStream);
// set to null on completion to allow lookups to begin using the new source
cacheDownloadRequest = null;
}
catch (Exception ex)
{
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
File.Delete(cacheFilePath);
}
finally
{
File.Delete(compressedCacheFilePath);
}
};
cacheDownloadRequest.PerformAsync();
}
private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
{
// download is in progress (or was, and failed).
if (cacheDownloadRequest != null)
return false;
// database is unavailable.
if (!storage.Exists(cache_database_name))
return false;
if (string.IsNullOrEmpty(beatmap.MD5Hash)
&& string.IsNullOrEmpty(beatmap.Path)
&& beatmap.OnlineBeatmapID == null)
return false;
try
{
using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage)))
{
db.Open();
using (var cmd = db.CreateCommand())
{
cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
beatmap.Status = status;
beatmap.BeatmapSet.Status = status;
beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
beatmap.OnlineBeatmapID = reader.GetInt32(1);
if (beatmap.Metadata != null)
beatmap.Metadata.AuthorID = reader.GetInt32(3);
if (beatmap.BeatmapSet.Metadata != null)
beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
logForModel(set, $"Cached local retrieval for {beatmap}.");
return true;
}
}
}
}
}
catch (Exception ex)
{
logForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
}
return false;
}
private void logForModel(BeatmapSetInfo set, string message) =>
ArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}");
public void Dispose()
{
cacheDownloadRequest?.Dispose();
updateScheduler?.Dispose();
}
}
}

View File

@ -176,8 +176,8 @@ namespace osu.Game.Beatmaps.Formats
case "L":
{
var startTime = Parsing.ParseDouble(split[1]);
var loopCount = Parsing.ParseInt(split[2]);
timelineGroup = storyboardSprite?.AddLoop(startTime, loopCount);
var repeatCount = Parsing.ParseInt(split[2]);
timelineGroup = storyboardSprite?.AddLoop(startTime, Math.Max(0, repeatCount - 1));
break;
}

View File

@ -0,0 +1,15 @@
// 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.
namespace osu.Game.Beatmaps
{
public interface IWorkingBeatmapCache
{
/// <summary>
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
/// </summary>
/// <param name="beatmapInfo">The beatmap to lookup.</param>
/// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo);
}
}

View File

@ -1,12 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// 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 System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Lists;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
@ -15,8 +21,96 @@ using osu.Game.Storyboards;
namespace osu.Game.Beatmaps
{
public partial class BeatmapManager
public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCache
{
private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
/// </summary>
public readonly WorkingBeatmap DefaultBeatmap;
public BeatmapModelManager BeatmapManager { private get; set; }
private readonly AudioManager audioManager;
private readonly IResourceStore<byte[]> resources;
private readonly LargeTextureStore largeTextureStore;
private readonly ITrackStore trackStore;
private readonly IResourceStore<byte[]> files;
[CanBeNull]
private readonly GameHost host;
public WorkingBeatmapCache([NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> files, WorkingBeatmap defaultBeatmap = null, GameHost host = null)
{
DefaultBeatmap = defaultBeatmap;
this.audioManager = audioManager;
this.resources = resources;
this.host = host;
this.files = files;
largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files));
trackStore = audioManager.GetTrackStore(files);
}
public void Invalidate(BeatmapSetInfo info)
{
if (info.Beatmaps == null) return;
foreach (var b in info.Beatmaps)
Invalidate(b);
}
public void Invalidate(BeatmapInfo info)
{
lock (workingCache)
{
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
if (working != null)
workingCache.Remove(working);
}
}
public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
{
// if there are no files, presume the full beatmap info has not yet been fetched from the database.
if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
{
int lookupId = beatmapInfo.ID;
beatmapInfo = BeatmapManager.QueryBeatmap(b => b.ID == lookupId);
}
if (beatmapInfo?.BeatmapSet == null)
return DefaultBeatmap;
lock (workingCache)
{
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
if (working != null)
return working;
beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
// best effort; may be higher than expected.
GlobalStatistics.Get<int>(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
return working;
}
}
#region IResourceStorageProvider
TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
AudioManager IStorageResourceProvider.AudioManager => audioManager;
IResourceStore<byte[]> IStorageResourceProvider.Files => files;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
#endregion
[ExcludeFromDynamicCompile]
private class BeatmapManagerWorkingBeatmap : WorkingBeatmap
{

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Legacy;
using osu.Game.Overlays.Notifications;
@ -27,7 +28,7 @@ namespace osu.Game.Collections
/// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
/// database backing the game. Going forward writing should be done in a similar way to other model stores.
/// </remarks>
public class CollectionManager : Component
public class CollectionManager : Component, IPostNotifications
{
/// <summary>
/// Database version in stable-compatible YYYYMMDD format.
@ -106,9 +107,6 @@ namespace osu.Game.Collections
backgroundSave();
});
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action<Notification> PostNotification { protected get; set; }
/// <summary>

View File

@ -30,7 +30,7 @@ namespace osu.Game.Database
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>, IModelFileManager<TModel, TFileModel>, IPresentImports<TModel>
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
where TFileModel : class, INamedFileInfo, new()
{
@ -57,9 +57,6 @@ namespace osu.Game.Database
/// </summary>
private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager<TModel, TFileModel>));
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action<Notification> PostNotification { protected get; set; }
/// <summary>
@ -135,7 +132,7 @@ namespace osu.Game.Database
return Import(notification, tasks);
}
protected async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
public async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
{
if (tasks.Length == 0)
{
@ -227,7 +224,7 @@ namespace osu.Game.Database
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The imported model, if successful.</returns>
internal async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
public async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@ -252,10 +249,7 @@ namespace osu.Game.Database
return import;
}
/// <summary>
/// Fired when the user requests to view the resulting import.
/// </summary>
public Action<IEnumerable<TModel>> PresentImport;
public Action<IEnumerable<TModel>> PresentImport { protected get; set; }
/// <summary>
/// Silently import an item from an <see cref="ArchiveReader"/>.
@ -479,7 +473,7 @@ namespace osu.Game.Database
/// </summary>
/// <param name="model">The item to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
protected virtual void ExportModelTo(TModel model, Stream outputStream)
public virtual void ExportModelTo(TModel model, Stream outputStream)
{
using (var archive = ZipArchive.Create())
{
@ -745,9 +739,6 @@ namespace osu.Game.Database
/// <returns>Whether to perform deletion.</returns>
protected virtual bool ShouldDeleteArchive(string path) => false;
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
public Task ImportFromStableAsync(StableStorage stableStorage)
{
var storage = PrepareStableStorage(stableStorage);
@ -805,6 +796,17 @@ namespace osu.Game.Database
/// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash);
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, ModelStore.ConsumableItems.Where(m => !m.DeletePending));
/// <summary>
/// Performs implementation specific comparisons to determine whether a given model is present in the local store.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
/// <param name="items">The usable items present in the store.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
/// <summary>
/// Whether import can be skipped after finding an existing import early in the process.
/// Only valid when <see cref="ComputeHash"/> is not overridden.
@ -841,7 +843,7 @@ namespace osu.Game.Database
private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>();
protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
#region Event handling / delaying

View File

@ -11,7 +11,7 @@ namespace osu.Game.Database
/// Represents a <see cref="IModelManager{TModel}"/> that can download new models from an external source.
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
public interface IModelDownloader<TModel> : IModelManager<TModel>
public interface IModelDownloader<TModel> : IPostNotifications
where TModel : class
{
/// <summary>
@ -26,13 +26,6 @@ namespace osu.Game.Database
/// </summary>
IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> DownloadFailed { get; }
/// <summary>
/// Checks whether a given <typeparamref name="TModel"/> is already available in the local store.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
bool IsAvailableLocally(TModel model);
/// <summary>
/// Begin a download for the requested <typeparamref name="TModel"/>.
/// </summary>

View File

@ -0,0 +1,36 @@
// 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.IO;
namespace osu.Game.Database
{
public interface IModelFileManager<in TModel, in TFileModel>
where TModel : class
where TFileModel : class
{
/// <summary>
/// Replace an existing file with a new version.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="file">The existing file to be replaced.</param>
/// <param name="contents">The new file contents.</param>
/// <param name="filename">An optional filename for the new file. Will use the previous filename if not specified.</param>
void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null);
/// <summary>
/// Delete an existing file.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="file">The existing file to be deleted.</param>
void DeleteFile(TModel model, TFileModel file);
/// <summary>
/// Add a new file.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="contents">The new file contents.</param>
/// <param name="filename">The filename for the new file.</param>
void AddFile(TModel model, Stream contents, string filename);
}
}

View File

@ -0,0 +1,65 @@
// 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.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.IO.Archives;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
/// <summary>
/// A class which handles importing of asociated models to the game store.
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
public interface IModelImporter<TModel> : IPostNotifications
where TModel : class
{
/// <summary>
/// Import one or more <typeparamref name="TModel"/> items from filesystem <paramref name="paths"/>.
/// </summary>
/// <remarks>
/// This will be treated as a low priority import if more than one path is specified; use <see cref="ArchiveModelManager{TModel,TFileModel}.Import(osu.Game.Database.ImportTask[])"/> to always import at standard priority.
/// This will post notifications tracking progress.
/// </remarks>
/// <param name="paths">One or more archive locations on disk.</param>
Task Import(params string[] paths);
Task Import(params ImportTask[] tasks);
Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks);
/// <summary>
/// Import one <typeparamref name="TModel"/> from the filesystem and delete the file on success.
/// Note that this bypasses the UI flow and should only be used for special cases or testing.
/// </summary>
/// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The imported model, if successful.</returns>
Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default);
/// <summary>
/// Silently import an item from an <see cref="ArchiveReader"/>.
/// </summary>
/// <param name="archive">The archive to be imported.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
Task<TModel> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default);
/// <summary>
/// Silently import an item from a <typeparamref name="TModel"/>.
/// </summary>
/// <param name="item">The model to be imported.</param>
/// <param name="archive">An optional archive to use for model population.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
Task<TModel> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default);
/// <summary>
/// A user displayable name for the model type associated with this manager.
/// </summary>
string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
}
}

View File

@ -1,8 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// 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 System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.IO;
namespace osu.Game.Database
{
@ -10,7 +14,7 @@ namespace osu.Game.Database
/// Represents a model manager that publishes events when <typeparamref name="TModel"/>s are added or removed.
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
public interface IModelManager<TModel>
public interface IModelManager<TModel> : IModelImporter<TModel>
where TModel : class
{
/// <summary>
@ -24,5 +28,63 @@ namespace osu.Game.Database
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindable<WeakReference<TModel>> ItemRemoved { get; }
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
Task ImportFromStableAsync(StableStorage stableStorage);
/// <summary>
/// Exports an item to a legacy (.zip based) package.
/// </summary>
/// <param name="item">The item to export.</param>
void Export(TModel item);
/// <summary>
/// Exports an item to the given output stream.
/// </summary>
/// <param name="model">The item to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
void ExportModelTo(TModel model, Stream outputStream);
/// <summary>
/// Perform an update of the specified item.
/// TODO: Support file additions/removals.
/// </summary>
/// <param name="item">The item to update.</param>
void Update(TModel item);
/// <summary>
/// Delete an item from the manager.
/// Is a no-op for already deleted items.
/// </summary>
/// <param name="item">The item to delete.</param>
/// <returns>false if no operation was performed</returns>
bool Delete(TModel item);
/// <summary>
/// Delete multiple items.
/// This will post notifications tracking progress.
/// </summary>
void Delete(List<TModel> items, bool silent = false);
/// <summary>
/// Restore multiple items that were previously deleted.
/// This will post notifications tracking progress.
/// </summary>
void Undelete(List<TModel> items, bool silent = false);
/// <summary>
/// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
/// </summary>
/// <param name="item">The item to restore</param>
void Undelete(TModel item);
/// <summary>
/// Checks whether a given <typeparamref name="TModel"/> is already available in the local store.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
bool IsAvailableLocally(TModel model);
}
}

View File

@ -0,0 +1,16 @@
// 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 osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
public interface IPostNotifications
{
/// <summary>
/// And action which will be fired when a notification should be presented to the user.
/// </summary>
public Action<Notification> PostNotification { set; }
}
}

View File

@ -0,0 +1,17 @@
// 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 System.Collections.Generic;
namespace osu.Game.Database
{
public interface IPresentImports<TModel>
where TModel : class
{
/// <summary>
/// Fired when the user requests to view the resulting import.
/// </summary>
public Action<IEnumerable<TModel>> PresentImport { set; }
}
}

View File

@ -9,20 +9,12 @@ namespace osu.Game.Database
{
/// <summary>
/// The main realm context, bound to the update thread.
/// If querying from a non-update thread is needed, use <see cref="GetForRead"/> or <see cref="GetForWrite"/> to receive a context instead.
/// </summary>
Realm Context { get; }
/// <summary>
/// Get a fresh context for read usage.
/// Create a new realm context for use on the current thread.
/// </summary>
RealmContextFactory.RealmUsage GetForRead();
/// <summary>
/// Request a context for write usage.
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
RealmContextFactory.RealmWriteUsage GetForWrite();
Realm CreateContext();
}
}

View File

@ -1,29 +1,24 @@
// 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 Humanizer;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Online.API;
using osu.Game.Overlays.Notifications;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Humanizer;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Online.API;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
/// <summary>
/// An <see cref="ArchiveModelManager{TModel, TFileModel}"/> that has the ability to download models using an <see cref="IAPIProvider"/> and
/// import them into the store.
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
public abstract class DownloadableArchiveModelManager<TModel, TFileModel> : ArchiveModelManager<TModel, TFileModel>, IModelDownloader<TModel>
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete, IEquatable<TModel>
where TFileModel : class, INamedFileInfo, new()
public abstract class ModelDownloader<TModel> : IModelDownloader<TModel>
where TModel : class, IHasPrimaryKey, ISoftDelete, IEquatable<TModel>
{
public Action<Notification> PostNotification { protected get; set; }
public IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> DownloadBegan => downloadBegan;
private readonly Bindable<WeakReference<ArchiveDownloadRequest<TModel>>> downloadBegan = new Bindable<WeakReference<ArchiveDownloadRequest<TModel>>>();
@ -32,18 +27,15 @@ namespace osu.Game.Database
private readonly Bindable<WeakReference<ArchiveDownloadRequest<TModel>>> downloadFailed = new Bindable<WeakReference<ArchiveDownloadRequest<TModel>>>();
private readonly IModelManager<TModel> modelManager;
private readonly IAPIProvider api;
private readonly List<ArchiveDownloadRequest<TModel>> currentDownloads = new List<ArchiveDownloadRequest<TModel>>();
private readonly MutableDatabaseBackedStoreWithFileIncludes<TModel, TFileModel> modelStore;
protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes<TModel, TFileModel> modelStore,
IIpcHost importHost = null)
: base(storage, contextFactory, modelStore, importHost)
protected ModelDownloader(IModelManager<TModel> modelManager, IAPIProvider api, IIpcHost importHost = null)
{
this.modelManager = modelManager;
this.api = api;
this.modelStore = modelStore;
}
/// <summary>
@ -54,12 +46,6 @@ namespace osu.Game.Database
/// <returns>The request object.</returns>
protected abstract ArchiveDownloadRequest<TModel> CreateDownloadRequest(TModel model, bool minimiseDownloadSize);
/// <summary>
/// Begin a download for the requested <typeparamref name="TModel"/>.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> to be downloaded.</param>
/// <param name="minimiseDownloadSize">Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle.</param>
/// <returns>Whether the download was started.</returns>
public bool Download(TModel model, bool minimiseDownloadSize = false)
{
if (!canDownload(model)) return false;
@ -82,7 +68,7 @@ namespace osu.Game.Database
Task.Factory.StartNew(async () =>
{
// This gets scheduled back to the update thread, but we want the import to run in the background.
var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false);
var imported = await modelManager.Import(notification, new ImportTask(filename)).ConfigureAwait(false);
// for now a failed import will be marked as a failed download for simplicity.
if (!imported.Any())
@ -117,21 +103,10 @@ namespace osu.Game.Database
notification.State = ProgressNotificationState.Cancelled;
if (!(error is OperationCanceledException))
Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!");
Logger.Error(error, $"{modelManager.HumanisedModelName.Titleize()} download failed!");
}
}
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending));
/// <summary>
/// Performs implementation specific comparisons to determine whether a given model is present in the local store.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
/// <param name="items">The usable items present in the store.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
public ArchiveDownloadRequest<TModel> GetExistingDownload(TModel model) => currentDownloads.Find(r => r.Model.Equals(model));
private bool canDownload(TModel model) => GetExistingDownload(model) == null && api != null;

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Development;
@ -10,80 +9,117 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Input.Bindings;
using Realms;
#nullable enable
namespace osu.Game.Database
{
/// <summary>
/// A factory which provides both the main (update thread bound) realm context and creates contexts for async usage.
/// </summary>
public class RealmContextFactory : Component, IRealmFactory
{
private readonly Storage storage;
private const string database_name = @"client";
/// <summary>
/// The filename of this realm.
/// </summary>
public readonly string Filename;
private const int schema_version = 6;
/// <summary>
/// Lock object which is held for the duration of a write operation (via <see cref="GetForWrite"/>).
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking context creation during blocking periods.
/// </summary>
private readonly object writeLock = new object();
private readonly SemaphoreSlim contextCreationLock = new SemaphoreSlim(1);
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections.
/// </summary>
private readonly SemaphoreSlim blockingLock = new SemaphoreSlim(1);
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)");
private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes");
private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>("Realm", "Contexts (Created)");
private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes");
private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages");
private readonly object updateContextLock = new object();
private Realm context;
private readonly object contextLock = new object();
private Realm? context;
public Realm Context
{
get
{
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException($"Use {nameof(GetForRead)} or {nameof(GetForWrite)} when performing realm operations from a non-update thread");
throw new InvalidOperationException($"Use {nameof(CreateContext)} when performing realm operations from a non-update thread");
lock (updateContextLock)
lock (contextLock)
{
if (context == null)
{
context = createContext();
context = CreateContext();
Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}");
}
// creating a context will ensure our schema is up-to-date and migrated.
return context;
}
}
}
public RealmContextFactory(Storage storage)
public RealmContextFactory(Storage storage, string filename)
{
this.storage = storage;
Filename = filename;
const string realm_extension = ".realm";
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
Filename += realm_extension;
}
public RealmUsage GetForRead()
/// <summary>
/// Compact this realm.
/// </summary>
/// <returns></returns>
public bool Compact() => Realm.Compact(getConfiguration());
protected override void Update()
{
reads.Value++;
return new RealmUsage(createContext());
base.Update();
lock (contextLock)
{
if (context?.Refresh() == true)
refreshes.Value++;
}
}
public RealmWriteUsage GetForWrite()
public Realm CreateContext()
{
writes.Value++;
pending_writes.Value++;
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
Monitor.Enter(writeLock);
return new RealmWriteUsage(createContext(), writeComplete);
try
{
contextCreationLock.Wait();
contexts_created.Value++;
return Realm.GetInstance(getConfiguration());
}
finally
{
contextCreationLock.Release();
}
}
private RealmConfiguration getConfiguration()
{
return new RealmConfiguration(storage.GetFullPath(Filename, true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
};
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
}
/// <summary>
@ -99,165 +135,63 @@ namespace osu.Game.Database
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
// TODO: this can be added for safety once we figure how to bypass in test
// if (!ThreadSafety.IsUpdateThread)
// throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread.");
Logger.Log(@"Blocking realm operations.", LoggingTarget.Database);
blockingLock.Wait();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, endBlockingSection);
static void endBlockingSection(RealmContextFactory factory)
{
factory.blockingLock.Release();
Logger.Log(@"Restoring realm operations.", LoggingTarget.Database);
}
}
protected override void Update()
{
base.Update();
lock (updateContextLock)
{
if (context?.Refresh() == true)
refreshes.Value++;
}
}
private Realm createContext()
{
try
{
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
contextCreationLock.Wait();
blockingLock.Wait();
contexts_created.Value++;
return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true))
lock (contextLock)
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
});
context?.Dispose();
context = null;
}
const int sleep_length = 200;
int timeout = 5000;
// see https://github.com/realm/realm-dotnet/discussions/2657
while (!Compact())
{
Thread.Sleep(sleep_length);
timeout -= sleep_length;
if (timeout < 0)
throw new TimeoutException("Took too long to acquire lock");
}
}
finally
catch
{
blockingLock.Release();
contextCreationLock.Release();
throw;
}
}
private void writeComplete()
{
Monitor.Exit(writeLock);
pending_writes.Value--;
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
switch (lastSchemaVersion)
return new InvokeOnDisposal<RealmContextFactory>(this, factory =>
{
case 5:
// let's keep things simple. changing the type of the primary key is a bit involved.
migration.NewRealm.RemoveAll<RealmKeyBinding>();
break;
}
}
private void flushContexts()
{
Logger.Log(@"Flushing realm contexts...", LoggingTarget.Database);
Debug.Assert(blockingLock.CurrentCount == 0);
Realm previousContext;
lock (updateContextLock)
{
previousContext = context;
context = null;
}
// wait for all threaded usages to finish
while (active_usages.Value > 0)
Thread.Sleep(50);
previousContext?.Dispose();
Logger.Log(@"Realm contexts flushed.", LoggingTarget.Database);
factory.contextCreationLock.Release();
Logger.Log(@"Restoring realm operations.", LoggingTarget.Database);
});
}
protected override void Dispose(bool isDisposing)
{
lock (contextLock)
{
context?.Dispose();
}
if (!IsDisposed)
{
// intentionally block all operations indefinitely. this ensures that nothing can start consuming a new context after disposal.
BlockAllOperations();
blockingLock?.Dispose();
// intentionally block context creation indefinitely. this ensures that nothing can start consuming a new context after disposal.
contextCreationLock.Wait();
contextCreationLock.Dispose();
}
base.Dispose(isDisposing);
}
/// <summary>
/// A usage of realm from an arbitrary thread.
/// </summary>
public class RealmUsage : IDisposable
{
public readonly Realm Realm;
internal RealmUsage(Realm context)
{
active_usages.Value++;
Realm = context;
}
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public virtual void Dispose()
{
Realm?.Dispose();
active_usages.Value--;
}
}
/// <summary>
/// A transaction used for making changes to realm data.
/// </summary>
public class RealmWriteUsage : RealmUsage
{
private readonly Action onWriteComplete;
private readonly Transaction transaction;
internal RealmWriteUsage(Realm context, Action onWriteComplete)
: base(context)
{
this.onWriteComplete = onWriteComplete;
transaction = Realm.BeginWrite();
}
/// <summary>
/// Commit all changes made in this transaction.
/// </summary>
public void Commit() => transaction.Commit();
/// <summary>
/// Revert all changes made in this transaction.
/// </summary>
public void Rollback() => transaction.Rollback();
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public override void Dispose()
{
// rollback if not explicitly committed.
transaction?.Dispose();
base.Dispose();
onWriteComplete();
}
}
}
}

View File

@ -1,51 +1,26 @@
// 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.Collections.Generic;
using AutoMapper;
using osu.Game.Input.Bindings;
using System;
using Realms;
namespace osu.Game.Database
{
public static class RealmExtensions
{
private static readonly IMapper mapper = new MapperConfiguration(c =>
public static void Write(this Realm realm, Action<Realm> function)
{
c.ShouldMapField = fi => false;
c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
}).CreateMapper();
/// <summary>
/// Create a detached copy of the each item in the collection.
/// </summary>
/// <param name="items">A list of managed <see cref="RealmObject"/>s to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A list containing non-managed copies of provided items.</returns>
public static List<T> Detach<T>(this IEnumerable<T> items) where T : RealmObject
{
var list = new List<T>();
foreach (var obj in items)
list.Add(obj.Detach());
return list;
using var transaction = realm.BeginWrite();
function(realm);
transaction.Commit();
}
/// <summary>
/// Create a detached copy of the item.
/// </summary>
/// <param name="item">The managed <see cref="RealmObject"/> to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A non-managed copy of provided item. Will return the provided item if already detached.</returns>
public static T Detach<T>(this T item) where T : RealmObject
public static T Write<T>(this Realm realm, Func<Realm, T> function)
{
if (!item.IsManaged)
return item;
return mapper.Map<T>(item);
using var transaction = realm.BeginWrite();
var result = function(realm);
transaction.Commit();
return result;
}
}
}

View File

@ -0,0 +1,51 @@
// 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.Collections.Generic;
using AutoMapper;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public static class RealmObjectExtensions
{
private static readonly IMapper mapper = new MapperConfiguration(c =>
{
c.ShouldMapField = fi => false;
c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
}).CreateMapper();
/// <summary>
/// Create a detached copy of the each item in the collection.
/// </summary>
/// <param name="items">A list of managed <see cref="RealmObject"/>s to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A list containing non-managed copies of provided items.</returns>
public static List<T> Detach<T>(this IEnumerable<T> items) where T : RealmObject
{
var list = new List<T>();
foreach (var obj in items)
list.Add(obj.Detach());
return list;
}
/// <summary>
/// Create a detached copy of the item.
/// </summary>
/// <param name="item">The managed <see cref="RealmObject"/> to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A non-managed copy of provided item. Will return the provided item if already detached.</returns>
public static T Detach<T>(this T item) where T : RealmObject
{
if (!item.IsManaged)
return item;
return mapper.Map<T>(item);
}
}
}

View File

@ -21,6 +21,8 @@ namespace osu.Game.Graphics
public static FontUsage Torus => GetFont(Typeface.Torus, weight: FontWeight.Regular);
public static FontUsage TorusAlternate => GetFont(Typeface.TorusAlternate, weight: FontWeight.Regular);
public static FontUsage Inter => GetFont(Typeface.Inter, weight: FontWeight.Regular);
/// <summary>
@ -57,6 +59,9 @@ namespace osu.Game.Graphics
case Typeface.Torus:
return "Torus";
case Typeface.TorusAlternate:
return "Torus-Alternate";
case Typeface.Inter:
return "Inter";
}
@ -113,6 +118,7 @@ namespace osu.Game.Graphics
{
Venera,
Torus,
TorusAlternate,
Inter,
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
using Realms;
#nullable enable
@ -30,9 +31,9 @@ namespace osu.Game.Input
{
List<string> combinations = new List<string>();
using (var context = realmFactory.GetForRead())
using (var context = realmFactory.CreateContext())
{
foreach (var action in context.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
foreach (var action in context.All<RealmKeyBinding>().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
{
string str = action.KeyCombination.ReadableString();
@ -52,26 +53,27 @@ namespace osu.Game.Input
/// <param name="rulesets">The rulesets to populate defaults from.</param>
public void Register(KeyBindingContainer container, IEnumerable<RulesetInfo> rulesets)
{
using (var usage = realmFactory.GetForWrite())
using (var realm = realmFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// intentionally flattened to a list rather than querying against the IQueryable, as nullable fields being queried against aren't indexed.
// this is much faster as a result.
var existingBindings = usage.Realm.All<RealmKeyBinding>().ToList();
var existingBindings = realm.All<RealmKeyBinding>().ToList();
insertDefaults(usage, existingBindings, container.DefaultKeyBindings);
insertDefaults(realm, existingBindings, container.DefaultKeyBindings);
foreach (var ruleset in rulesets)
{
var instance = ruleset.CreateInstance();
foreach (var variant in instance.AvailableVariants)
insertDefaults(usage, existingBindings, instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
insertDefaults(realm, existingBindings, instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
}
usage.Commit();
transaction.Commit();
}
}
private void insertDefaults(RealmContextFactory.RealmUsage usage, List<RealmKeyBinding> existingBindings, IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
private void insertDefaults(Realm realm, List<RealmKeyBinding> existingBindings, IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
// compare counts in database vs defaults for each action type.
foreach (var defaultsForAction in defaults.GroupBy(k => k.Action))
@ -83,7 +85,7 @@ namespace osu.Game.Input
continue;
// insert any defaults which are missing.
usage.Realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding
realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding
{
KeyCombinationString = k.KeyCombination.ToString(),
ActionInt = (int)k.Action,

View File

@ -16,7 +16,7 @@ namespace osu.Game.Online
/// </summary>
public abstract class DownloadTrackingComposite<TModel, TModelManager> : CompositeDrawable
where TModel : class, IEquatable<TModel>
where TModelManager : class, IModelDownloader<TModel>
where TModelManager : class, IModelDownloader<TModel>, IModelManager<TModel>
{
protected readonly Bindable<TModel> Model = new Bindable<TModel>();
@ -35,7 +35,7 @@ namespace osu.Game.Online
Model.Value = model;
}
private IBindable<WeakReference<TModel>> managedUpdated;
private IBindable<WeakReference<TModel>> managerUpdated;
private IBindable<WeakReference<TModel>> managerRemoved;
private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadBegan;
private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadFailed;
@ -60,8 +60,8 @@ namespace osu.Game.Online
managerDownloadBegan.BindValueChanged(downloadBegan);
managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy();
managerDownloadFailed.BindValueChanged(downloadFailed);
managedUpdated = Manager.ItemUpdated.GetBoundCopy();
managedUpdated.BindValueChanged(itemUpdated);
managerUpdated = Manager.ItemUpdated.GetBoundCopy();
managerUpdated.BindValueChanged(itemUpdated);
managerRemoved = Manager.ItemRemoved.GetBoundCopy();
managerRemoved.BindValueChanged(itemRemoved);
}
@ -77,7 +77,7 @@ namespace osu.Game.Online
/// <summary>
/// Whether the given model is available in the database.
/// By default, this calls <see cref="IModelDownloader{TModel}.IsAvailableLocally"/>,
/// By default, this calls <see cref="IModelManager{TModel}.IsAvailableLocally"/>,
/// but can be overriden to add additional checks for verifying the model in database.
/// </summary>
protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true;

View File

@ -134,7 +134,7 @@ namespace osu.Game.Online.Spectator
return Task.CompletedTask;
}
public void BeginPlaying(GameplayBeatmap beatmap, Score score)
public void BeginPlaying(GameplayState state, Score score)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
@ -148,7 +148,7 @@ namespace osu.Game.Online.Spectator
currentState.RulesetID = score.ScoreInfo.RulesetID;
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
currentBeatmap = beatmap.PlayableBeatmap;
currentBeatmap = state.Beatmap;
currentScore = score;
BeginPlayingInternal(currentState);

View File

@ -184,7 +184,7 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client"));
AddInternal(realmFactory);
@ -236,7 +236,7 @@ namespace osu.Game
// ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup()
dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig));
dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true));
dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true));
// this should likely be moved to ArchiveModelManager when another case appears where it is necessary
// to have inter-dependent model managers. this could be obtained with an IHasForeign<T> interface to
@ -341,6 +341,11 @@ namespace osu.Game
AddFont(Resources, @"Fonts/Torus/Torus-SemiBold");
AddFont(Resources, @"Fonts/Torus/Torus-Bold");
AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Regular");
AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Light");
AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-SemiBold");
AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Bold");
AddFont(Resources, @"Fonts/Inter/Inter-Regular");
AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic");
AddFont(Resources, @"Fonts/Inter/Inter-Light");
@ -425,19 +430,20 @@ namespace osu.Game
private void migrateDataToRealm()
{
using (var db = contextFactory.GetForWrite())
using (var usage = realmFactory.GetForWrite())
using (var realm = realmFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.Context.DatabasedSetting;
// only migrate data if the realm database is empty.
if (!usage.Realm.All<RealmRulesetSetting>().Any())
if (!realm.All<RealmRulesetSetting>().Any())
{
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null) continue;
usage.Realm.Add(new RealmRulesetSetting
realm.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
@ -449,7 +455,7 @@ namespace osu.Game
db.Context.RemoveRange(existingSettings);
usage.Commit();
transaction.Commit();
}
}

View File

@ -284,6 +284,10 @@ namespace osu.Game.Overlays
if (currentChannel.Value != e.NewValue)
return;
// check once more to ensure the channel hasn't since been removed from the loaded channels list (may have been left by some automated means).
if (loadedChannels.Contains(loaded))
return;
loading.Hide();
currentChannelContainer.Clear(false);
@ -444,10 +448,9 @@ namespace osu.Game.Overlays
if (loaded != null)
{
loadedChannels.Remove(loaded);
// Because the container is only cleared in the async load callback of a new channel, it is forcefully cleared
// to ensure that the previous channel doesn't get updated after it's disposed
loadedChannels.Remove(loaded);
currentChannelContainer.Remove(loaded);
loaded.Dispose();
}

View File

@ -368,12 +368,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private void updateStoreFromButton(KeyButton button)
{
using (var usage = realmFactory.GetForWrite())
using (var realm = realmFactory.CreateContext())
{
var binding = usage.Realm.Find<RealmKeyBinding>(((IHasGuidPrimaryKey)button.KeyBinding).ID);
binding.KeyCombinationString = button.KeyBinding.KeyCombinationString;
usage.Commit();
var binding = realm.Find<RealmKeyBinding>(((IHasGuidPrimaryKey)button.KeyBinding).ID);
realm.Write(() => binding.KeyCombinationString = button.KeyBinding.KeyCombinationString);
}
}

View File

@ -38,8 +38,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
List<RealmKeyBinding> bindings;
using (var usage = realmFactory.GetForRead())
bindings = usage.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
using (var realm = realmFactory.CreateContext())
bindings = realm.All<RealmKeyBinding>().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
foreach (var defaultGroup in Defaults.GroupBy(d => d.Action))
{
@ -75,5 +75,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
Content.CornerRadius = 5;
}
// Empty FilterTerms so that the ResetButton is visible only when the whole subsection is visible.
public override IEnumerable<string> FilterTerms => Enumerable.Empty<string>();
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.UI
private SpectatorClient spectatorClient { get; set; }
[Resolved]
private GameplayBeatmap gameplayBeatmap { get; set; }
private GameplayState gameplayState { get; set; }
protected ReplayRecorder(Score target)
{
@ -49,7 +49,7 @@ namespace osu.Game.Rulesets.UI
inputManager = GetContainingInputManager();
spectatorClient?.BeginPlaying(gameplayBeatmap, target);
spectatorClient?.BeginPlaying(gameplayState, target);
}
protected override void Dispose(bool isDisposing)

View File

@ -9,102 +9,48 @@ using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring.Legacy;
namespace osu.Game.Scoring
{
public class ScoreManager : DownloadableArchiveModelManager<ScoreInfo, ScoreFileInfo>
public class ScoreManager : IModelManager<ScoreInfo>, IModelFileManager<ScoreInfo, ScoreFileInfo>, IModelDownloader<ScoreInfo>, ICanAcceptFiles, IPresentImports<ScoreInfo>
{
public override IEnumerable<string> HandledExtensions => new[] { ".osr" };
protected override string[] HashableFileTypes => new[] { ".osr" };
protected override string ImportFromStablePath => Path.Combine("Data", "r");
private readonly RulesetStore rulesets;
private readonly Func<BeatmapManager> beatmaps;
private readonly Scheduler scheduler;
[CanBeNull]
private readonly Func<BeatmapDifficultyCache> difficulties;
[CanBeNull]
private readonly OsuConfigManager configManager;
private readonly ScoreModelManager scoreModelManager;
private readonly ScoreModelDownloader scoreModelDownloader;
public ScoreManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, Scheduler scheduler,
IIpcHost importHost = null, Func<BeatmapDifficultyCache> difficulties = null, OsuConfigManager configManager = null)
: base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost)
{
this.rulesets = rulesets;
this.beatmaps = beatmaps;
this.scheduler = scheduler;
this.difficulties = difficulties;
this.configManager = configManager;
scoreModelManager = new ScoreModelManager(rulesets, beatmaps, storage, contextFactory, importHost);
scoreModelDownloader = new ScoreModelDownloader(scoreModelManager, api, importHost);
}
protected override ScoreInfo CreateModel(ArchiveReader archive)
{
if (archive == null)
return null;
public Score GetScore(ScoreInfo score) => scoreModelManager.GetScore(score);
using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase))))
{
try
{
return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo;
}
catch (LegacyScoreDecoder.BeatmapNotFoundException e)
{
Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error);
return null;
}
}
}
public List<ScoreInfo> GetAllUsableScores() => scoreModelManager.GetAllUsableScores();
protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => scoreModelManager.QueryScores(query);
protected override void ExportModelTo(ScoreInfo model, Stream outputStream)
{
var file = model.Files.SingleOrDefault();
if (file == null)
return;
using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath))
inputStream.CopyTo(outputStream);
}
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
.Select(path => storage.GetFullPath(path));
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
public List<ScoreInfo> GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query);
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
protected override ArchiveDownloadRequest<ScoreInfo> CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score);
protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items)
=> base.CheckLocalAvailability(model, items)
|| (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID));
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => scoreModelManager.Query(query);
/// <summary>
/// Orders an array of <see cref="ScoreInfo"/>s by total score.
@ -281,5 +227,149 @@ namespace osu.Game.Scoring
this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true);
}
}
#region Implementation of IPostNotifications
public Action<Notification> PostNotification
{
set
{
scoreModelManager.PostNotification = value;
scoreModelDownloader.PostNotification = value;
}
}
#endregion
#region Implementation of IModelManager<ScoreInfo>
public IBindable<WeakReference<ScoreInfo>> ItemUpdated => scoreModelManager.ItemUpdated;
public IBindable<WeakReference<ScoreInfo>> ItemRemoved => scoreModelManager.ItemRemoved;
public Task ImportFromStableAsync(StableStorage stableStorage)
{
return scoreModelManager.ImportFromStableAsync(stableStorage);
}
public void Export(ScoreInfo item)
{
scoreModelManager.Export(item);
}
public void ExportModelTo(ScoreInfo model, Stream outputStream)
{
scoreModelManager.ExportModelTo(model, outputStream);
}
public void Update(ScoreInfo item)
{
scoreModelManager.Update(item);
}
public bool Delete(ScoreInfo item)
{
return scoreModelManager.Delete(item);
}
public void Delete(List<ScoreInfo> items, bool silent = false)
{
scoreModelManager.Delete(items, silent);
}
public void Undelete(List<ScoreInfo> items, bool silent = false)
{
scoreModelManager.Undelete(items, silent);
}
public void Undelete(ScoreInfo item)
{
scoreModelManager.Undelete(item);
}
public Task Import(params string[] paths)
{
return scoreModelManager.Import(paths);
}
public Task Import(params ImportTask[] tasks)
{
return scoreModelManager.Import(tasks);
}
public IEnumerable<string> HandledExtensions => scoreModelManager.HandledExtensions;
public Task<IEnumerable<ScoreInfo>> Import(ProgressNotification notification, params ImportTask[] tasks)
{
return scoreModelManager.Import(notification, tasks);
}
public Task<ScoreInfo> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return scoreModelManager.Import(task, lowPriority, cancellationToken);
}
public Task<ScoreInfo> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return scoreModelManager.Import(archive, lowPriority, cancellationToken);
}
public Task<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return scoreModelManager.Import(item, archive, lowPriority, cancellationToken);
}
public bool IsAvailableLocally(ScoreInfo model)
{
return scoreModelManager.IsAvailableLocally(model);
}
#endregion
#region Implementation of IModelFileManager<in ScoreInfo,in ScoreFileInfo>
public void ReplaceFile(ScoreInfo model, ScoreFileInfo file, Stream contents, string filename = null)
{
scoreModelManager.ReplaceFile(model, file, contents, filename);
}
public void DeleteFile(ScoreInfo model, ScoreFileInfo file)
{
scoreModelManager.DeleteFile(model, file);
}
public void AddFile(ScoreInfo model, Stream contents, string filename)
{
scoreModelManager.AddFile(model, contents, filename);
}
#endregion
#region Implementation of IModelDownloader<ScoreInfo>
public IBindable<WeakReference<ArchiveDownloadRequest<ScoreInfo>>> DownloadBegan => scoreModelDownloader.DownloadBegan;
public IBindable<WeakReference<ArchiveDownloadRequest<ScoreInfo>>> DownloadFailed => scoreModelDownloader.DownloadFailed;
public bool Download(ScoreInfo model, bool minimiseDownloadSize)
{
return scoreModelDownloader.Download(model, minimiseDownloadSize);
}
public ArchiveDownloadRequest<ScoreInfo> GetExistingDownload(ScoreInfo model)
{
return scoreModelDownloader.GetExistingDownload(model);
}
#endregion
#region Implementation of IPresentImports<ScoreInfo>
public Action<IEnumerable<ScoreInfo>> PresentImport
{
set => scoreModelManager.PresentImport = value;
}
#endregion
}
}

View File

@ -0,0 +1,20 @@
// 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.Framework.Platform;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
namespace osu.Game.Scoring
{
public class ScoreModelDownloader : ModelDownloader<ScoreInfo>
{
public ScoreModelDownloader(ScoreModelManager scoreManager, IAPIProvider api, IIpcHost importHost = null)
: base(scoreManager, api, importHost)
{
}
protected override ArchiveDownloadRequest<ScoreInfo> CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score);
}
}

View File

@ -0,0 +1,88 @@
// 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 System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO.Archives;
using osu.Game.Rulesets;
using osu.Game.Scoring.Legacy;
namespace osu.Game.Scoring
{
public class ScoreModelManager : ArchiveModelManager<ScoreInfo, ScoreFileInfo>
{
public override IEnumerable<string> HandledExtensions => new[] { ".osr" };
protected override string[] HashableFileTypes => new[] { ".osr" };
protected override string ImportFromStablePath => Path.Combine("Data", "r");
private readonly RulesetStore rulesets;
private readonly Func<BeatmapManager> beatmaps;
public ScoreModelManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IDatabaseContextFactory contextFactory, IIpcHost importHost = null)
: base(storage, contextFactory, new ScoreStore(contextFactory, storage), importHost)
{
this.rulesets = rulesets;
this.beatmaps = beatmaps;
}
protected override ScoreInfo CreateModel(ArchiveReader archive)
{
if (archive == null)
return null;
using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase))))
{
try
{
return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo;
}
catch (LegacyScoreDecoder.BeatmapNotFoundException e)
{
Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error);
return null;
}
}
}
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
public List<ScoreInfo> GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query);
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items)
=> base.CheckLocalAvailability(model, items)
|| (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID));
public override void ExportModelTo(ScoreInfo model, Stream outputStream)
{
var file = model.Files.SingleOrDefault();
if (file == null)
return;
using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath))
inputStream.CopyTo(outputStream);
}
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
.Select(path => storage.GetFullPath(path));
}
}

View File

@ -72,21 +72,21 @@ namespace osu.Game.Screens.OnlinePlay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.GetFont(size: 24),
Font = OsuFont.TorusAlternate.With(size: 24),
Text = mainTitle
},
dot = new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.GetFont(size: 24),
Font = OsuFont.TorusAlternate.With(size: 24),
Text = "·"
},
pageTitle = new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.GetFont(size: 24),
Font = OsuFont.TorusAlternate.With(size: 24),
Text = "Lounge"
}
}

View File

@ -213,8 +213,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
}
protected override void StartGameplay(int userId, GameplayState gameplayState)
=> instances.Single(i => i.UserId == userId).LoadScore(gameplayState.Score);
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
=> instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score);
protected override void EndGameplay(int userId)
{

View File

@ -1,56 +0,0 @@
// 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.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Play
{
public class GameplayBeatmap : Component, IBeatmap
{
public readonly IBeatmap PlayableBeatmap;
public GameplayBeatmap(IBeatmap playableBeatmap)
{
PlayableBeatmap = playableBeatmap;
}
public BeatmapInfo BeatmapInfo
{
get => PlayableBeatmap.BeatmapInfo;
set => PlayableBeatmap.BeatmapInfo = value;
}
public BeatmapMetadata Metadata => PlayableBeatmap.Metadata;
public ControlPointInfo ControlPointInfo
{
get => PlayableBeatmap.ControlPointInfo;
set => PlayableBeatmap.ControlPointInfo = value;
}
public List<BreakPeriod> Breaks => PlayableBeatmap.Breaks;
public double TotalBreakTime => PlayableBeatmap.TotalBreakTime;
public IReadOnlyList<HitObject> HitObjects => PlayableBeatmap.HitObjects;
public IEnumerable<BeatmapStatistic> GetStatistics() => PlayableBeatmap.GetStatistics();
public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength();
public IBeatmap Clone() => PlayableBeatmap.Clone();
private readonly Bindable<JudgementResult> lastJudgementResult = new Bindable<JudgementResult>();
public IBindable<JudgementResult> LastJudgementResult => lastJudgementResult;
public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result;
}
}

View File

@ -0,0 +1,55 @@
// 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.Collections.Generic;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
#nullable enable
namespace osu.Game.Screens.Play
{
/// <summary>
/// The state of an active gameplay session, generally constructed and exposed by <see cref="Player"/>.
/// </summary>
public class GameplayState
{
/// <summary>
/// The final post-convert post-mod-application beatmap.
/// </summary>
public readonly IBeatmap Beatmap;
/// <summary>
/// The ruleset used in gameplay.
/// </summary>
public readonly Ruleset Ruleset;
/// <summary>
/// The mods applied to the gameplay.
/// </summary>
public IReadOnlyList<Mod> Mods;
/// <summary>
/// A bindable tracking the last judgement result applied to any hit object.
/// </summary>
public IBindable<JudgementResult> LastJudgementResult => lastJudgementResult;
private readonly Bindable<JudgementResult> lastJudgementResult = new Bindable<JudgementResult>();
public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList<Mod> mods)
{
Beatmap = beatmap;
Ruleset = ruleset;
Mods = mods;
}
/// <summary>
/// Applies the score change of a <see cref="JudgementResult"/> to this <see cref="GameplayState"/>.
/// </summary>
/// <param name="result">The <see cref="JudgementResult"/> to apply.</param>
public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result;
}
}

View File

@ -93,9 +93,9 @@ namespace osu.Game.Screens.Play
[Resolved]
private SpectatorClient spectatorClient { get; set; }
protected Ruleset GameplayRuleset { get; private set; }
public GameplayState GameplayState { get; private set; }
protected GameplayBeatmap GameplayBeatmap { get; private set; }
private Ruleset ruleset;
private Sample sampleRestart;
@ -165,7 +165,7 @@ namespace osu.Game.Screens.Play
// ensure the score is in a consistent state with the current player.
Score.ScoreInfo.Beatmap = Beatmap.Value.BeatmapInfo;
Score.ScoreInfo.Ruleset = GameplayRuleset.RulesetInfo;
Score.ScoreInfo.Ruleset = ruleset.RulesetInfo;
Score.ScoreInfo.Mods = Mods.Value.ToArray();
PrepareReplay();
@ -206,16 +206,16 @@ namespace osu.Game.Screens.Play
if (game is OsuGame osuGame)
LocalUserPlaying.BindTo(osuGame.LocalUserPlaying);
DrawableRuleset = GameplayRuleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value);
DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value);
dependencies.CacheAs(DrawableRuleset);
ScoreProcessor = GameplayRuleset.CreateScoreProcessor();
ScoreProcessor = ruleset.CreateScoreProcessor();
ScoreProcessor.ApplyBeatmap(playableBeatmap);
ScoreProcessor.Mods.BindTo(Mods);
dependencies.CacheAs(ScoreProcessor);
HealthProcessor = GameplayRuleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime);
HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime);
HealthProcessor.ApplyBeatmap(playableBeatmap);
dependencies.CacheAs(HealthProcessor);
@ -225,12 +225,11 @@ namespace osu.Game.Screens.Play
InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime);
AddInternal(GameplayBeatmap = new GameplayBeatmap(playableBeatmap));
dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, Mods.Value));
AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer));
dependencies.CacheAs(GameplayBeatmap);
var rulesetSkinProvider = new RulesetSkinProvidingContainer(GameplayRuleset, playableBeatmap, Beatmap.Value.Skin);
var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin);
// load the skinning hierarchy first.
// this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources.
@ -280,7 +279,7 @@ namespace osu.Game.Screens.Play
{
HealthProcessor.ApplyResult(r);
ScoreProcessor.ApplyResult(r);
GameplayBeatmap.ApplyResult(r);
GameplayState.ApplyResult(r);
};
DrawableRuleset.RevertResult += r =>
@ -478,17 +477,17 @@ namespace osu.Game.Screens.Play
throw new InvalidOperationException("Beatmap was not loaded");
var rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset;
GameplayRuleset = rulesetInfo.CreateInstance();
ruleset = rulesetInfo.CreateInstance();
try
{
playable = Beatmap.Value.GetPlayableBeatmap(GameplayRuleset.RulesetInfo, Mods.Value);
playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Mods.Value);
}
catch (BeatmapInvalidForRulesetException)
{
// A playable beatmap may not be creatable with the user's preferred ruleset, so try using the beatmap's default ruleset
rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset;
GameplayRuleset = rulesetInfo.CreateInstance();
ruleset = rulesetInfo.CreateInstance();
playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, Mods.Value);
}
@ -1010,7 +1009,7 @@ namespace osu.Game.Screens.Play
using (var stream = new MemoryStream())
{
new LegacyScoreEncoder(score, GameplayBeatmap.PlayableBeatmap).Encode(stream);
new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream);
replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr");
}

View File

@ -41,7 +41,7 @@ namespace osu.Game.Screens.Play
DrawableRuleset?.SetReplayScore(Score);
}
protected override Score CreateScore() => createScore(GameplayBeatmap.PlayableBeatmap, Mods.Value);
protected override Score CreateScore() => createScore(GameplayState.Beatmap, Mods.Value);
// Don't re-import replay scores as they're already present in the database.
protected override Task ImportScore(Score score) => Task.CompletedTask;
@ -78,7 +78,7 @@ namespace osu.Game.Screens.Play
void keyboardSeek(int direction)
{
double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayBeatmap.HitObjects.Last().GetEndTime());
double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayState.Beatmap.HitObjects.Last().GetEndTime());
Seek(target);
}

View File

@ -56,7 +56,7 @@ namespace osu.Game.Screens.Play
/// The player's immediate online gameplay state.
/// This doesn't always reflect the gameplay state being watched.
/// </summary>
private GameplayState immediateGameplayState;
private SpectatorGameplayState immediateSpectatorGameplayState;
private GetBeatmapSetRequest onlineBeatmapRequest;
@ -146,7 +146,7 @@ namespace osu.Game.Screens.Play
Width = 250,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Action = () => scheduleStart(immediateGameplayState),
Action = () => scheduleStart(immediateSpectatorGameplayState),
Enabled = { Value = false }
}
}
@ -167,18 +167,18 @@ namespace osu.Game.Screens.Play
showBeatmapPanel(spectatorState);
}
protected override void StartGameplay(int userId, GameplayState gameplayState)
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
{
immediateGameplayState = gameplayState;
immediateSpectatorGameplayState = spectatorGameplayState;
watchButton.Enabled.Value = true;
scheduleStart(gameplayState);
scheduleStart(spectatorGameplayState);
}
protected override void EndGameplay(int userId)
{
scheduledStart?.Cancel();
immediateGameplayState = null;
immediateSpectatorGameplayState = null;
watchButton.Enabled.Value = false;
clearDisplay();
@ -194,7 +194,7 @@ namespace osu.Game.Screens.Play
private ScheduledDelegate scheduledStart;
private void scheduleStart(GameplayState gameplayState)
private void scheduleStart(SpectatorGameplayState spectatorGameplayState)
{
// This function may be called multiple times in quick succession once the screen becomes current again.
scheduledStart?.Cancel();
@ -203,15 +203,15 @@ namespace osu.Game.Screens.Play
if (this.IsCurrentScreen())
start();
else
scheduleStart(gameplayState);
scheduleStart(spectatorGameplayState);
});
void start()
{
Beatmap.Value = gameplayState.Beatmap;
Ruleset.Value = gameplayState.Ruleset.RulesetInfo;
Beatmap.Value = spectatorGameplayState.Beatmap;
Ruleset.Value = spectatorGameplayState.Ruleset.RulesetInfo;
this.Push(new SpectatorPlayerLoader(gameplayState.Score, () => new SoloSpectatorPlayer(gameplayState.Score)));
this.Push(new SpectatorPlayerLoader(spectatorGameplayState.Score, () => new SoloSpectatorPlayer(spectatorGameplayState.Score)));
}
}

View File

@ -66,8 +66,8 @@ namespace osu.Game.Screens.Play
foreach (var frame in bundle.Frames)
{
IConvertibleReplayFrame convertibleFrame = GameplayRuleset.CreateConvertibleReplayFrame();
convertibleFrame.FromLegacy(frame, GameplayBeatmap.PlayableBeatmap);
IConvertibleReplayFrame convertibleFrame = GameplayState.Ruleset.CreateConvertibleReplayFrame();
convertibleFrame.FromLegacy(frame, GameplayState.Beatmap);
var convertedFrame = (ReplayFrame)convertibleFrame;
convertedFrame.Time = frame.Time;

View File

@ -40,7 +40,7 @@ namespace osu.Game.Screens.Ranking
}
[BackgroundDependencyLoader(true)]
private void load(OsuGame game, ScoreManager scores)
private void load(OsuGame game, ScoreModelDownloader scores)
{
InternalChild = shakeContainer = new ShakeContainer
{

View File

@ -8,9 +8,9 @@ using osu.Game.Scoring;
namespace osu.Game.Screens.Spectate
{
/// <summary>
/// The gameplay state of a spectated user. This class is immutable.
/// An immutable spectator gameplay state.
/// </summary>
public class GameplayState
public class SpectatorGameplayState
{
/// <summary>
/// The score which the user is playing.
@ -27,7 +27,7 @@ namespace osu.Game.Screens.Spectate
/// </summary>
public readonly WorkingBeatmap Beatmap;
public GameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap)
public SpectatorGameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap)
{
Score = score;
Ruleset = ruleset;

View File

@ -43,7 +43,7 @@ namespace osu.Game.Screens.Spectate
private readonly IBindableDictionary<int, SpectatorState> playingUserStates = new BindableDictionary<int, SpectatorState>();
private readonly Dictionary<int, User> userMap = new Dictionary<int, User>();
private readonly Dictionary<int, GameplayState> gameplayStates = new Dictionary<int, GameplayState>();
private readonly Dictionary<int, SpectatorGameplayState> gameplayStates = new Dictionary<int, SpectatorGameplayState>();
private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated;
@ -173,7 +173,7 @@ namespace osu.Game.Screens.Spectate
Replay = new Replay { HasReceivedAllFrames = false },
};
var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
var gameplayState = new SpectatorGameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
gameplayStates[userId] = gameplayState;
Schedule(() => StartGameplay(userId, gameplayState));
@ -190,8 +190,8 @@ namespace osu.Game.Screens.Spectate
/// Starts gameplay for a user.
/// </summary>
/// <param name="userId">The user to start gameplay for.</param>
/// <param name="gameplayState">The gameplay state.</param>
protected abstract void StartGameplay(int userId, [NotNull] GameplayState gameplayState);
/// <param name="spectatorGameplayState">The gameplay state.</param>
protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState);
/// <summary>
/// Ends gameplay for a user.

View File

@ -11,6 +11,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
using osu.Framework.Logging;
using osu.Game.Audio;
using osu.Game.IO;
using osu.Game.Screens.Play.HUD;
@ -55,13 +56,20 @@ namespace osu.Game.Skinning
if (bytes == null)
continue;
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
try
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
if (deserializedContent == null)
continue;
if (deserializedContent == null)
continue;
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
}

View File

@ -1,6 +1,7 @@
// 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 System.Collections.Generic;
namespace osu.Game.Storyboards
@ -8,20 +9,31 @@ namespace osu.Game.Storyboards
public class CommandLoop : CommandTimelineGroup
{
public double LoopStartTime;
public int LoopCount;
/// <summary>
/// The total number of times this loop is played back. Always greater than zero.
/// </summary>
public readonly int TotalIterations;
public override double StartTime => LoopStartTime + CommandsStartTime;
public override double EndTime => StartTime + CommandsDuration * LoopCount;
public override double EndTime => StartTime + CommandsDuration * TotalIterations;
public CommandLoop(double startTime, int loopCount)
/// <summary>
/// Construct a new command loop.
/// </summary>
/// <param name="startTime">The start time of the loop.</param>
/// <param name="repeatCount">The number of times the loop should repeat. Should be greater than zero. Zero means a single playback.</param>
public CommandLoop(double startTime, int repeatCount)
{
if (repeatCount < 0) throw new ArgumentException("Repeat count must be zero or above.", nameof(repeatCount));
LoopStartTime = startTime;
LoopCount = loopCount;
TotalIterations = repeatCount + 1;
}
public override IEnumerable<CommandTimeline<T>.TypedCommand> GetCommands<T>(CommandTimelineSelector<T> timelineSelector, double offset = 0)
{
for (var loop = 0; loop < LoopCount; loop++)
for (var loop = 0; loop < TotalIterations; loop++)
{
var loopOffset = LoopStartTime + loop * CommandsDuration;
foreach (var command in base.GetCommands(timelineSelector, offset + loopOffset))
@ -30,6 +42,6 @@ namespace osu.Game.Storyboards
}
public override string ToString()
=> $"{LoopStartTime} x{LoopCount}";
=> $"{LoopStartTime} x{TotalIterations}";
}
}

View File

@ -1,13 +1,13 @@
// 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 osuTK;
using osu.Framework.Graphics;
using osu.Game.Storyboards.Drawables;
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Game.Storyboards.Drawables;
using osuTK;
namespace osu.Game.Storyboards
{
@ -78,9 +78,9 @@ namespace osu.Game.Storyboards
InitialPosition = initialPosition;
}
public CommandLoop AddLoop(double startTime, int loopCount)
public CommandLoop AddLoop(double startTime, int repeatCount)
{
var loop = new CommandLoop(startTime, loopCount);
var loop = new CommandLoop(startTime, repeatCount);
loops.Add(loop);
return loop;
}

View File

@ -123,11 +123,40 @@ namespace osu.Game.Tests.Visual
this.testBeatmap = testBeatmap;
}
protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
=> string.Empty;
protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
{
return new TestBeatmapModelManager(storage, contextFactory, rulesets, api, host);
}
public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
=> testBeatmap;
protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host)
{
return new TestWorkingBeatmapCache(this, audioManager, resources, storage, defaultBeatmap, host);
}
private class TestWorkingBeatmapCache : WorkingBeatmapCache
{
private readonly TestBeatmapManager testBeatmapManager;
public TestWorkingBeatmapCache(TestBeatmapManager testBeatmapManager, AudioManager audioManager, IResourceStore<byte[]> resourceStore, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost gameHost)
: base(audioManager, resourceStore, storage, defaultBeatmap, gameHost)
{
this.testBeatmapManager = testBeatmapManager;
}
public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
=> testBeatmapManager.testBeatmap;
}
internal class TestBeatmapModelManager : BeatmapModelManager
{
public TestBeatmapModelManager(Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
: base(storage, databaseContextFactory, rulesetStore, gameHost)
{
}
protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
=> string.Empty;
}
public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
{

View File

@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual
if (autoplayMod != null)
{
DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayBeatmap.PlayableBeatmap, Mods.Value));
DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayState.Beatmap, Mods.Value));
return;
}

View File

@ -20,12 +20,12 @@
<ItemGroup Label="Package References">
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.36" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.37" />
<PackageReference Include="Humanizer" Version="2.11.10" />
<PackageReference Include="MessagePack" Version="2.3.75" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.9" />
<PackageReference Include="MessagePack" Version="2.3.85" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
@ -37,8 +37,8 @@
</PackageReference>
<PackageReference Include="Realm" Version="10.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1004.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.918.0" />
<PackageReference Include="Sentry" Version="3.9.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
<PackageReference Include="Sentry" Version="3.9.4" />
<PackageReference Include="SharpCompress" Version="0.29.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

View File

@ -71,7 +71,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1004.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.918.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
<PropertyGroup>
@ -99,6 +99,6 @@
<PackageReference Include="SharpRaven" Version="2.4.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="ppy.osu.Framework.NativeLibs" Version="2021.805.0" ExcludeAssets="all" />
<PackageReference Include="Realm" Version="10.5.0" />
<PackageReference Include="Realm" Version="10.6.0" />
</ItemGroup>
</Project>