1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-13 19:04:06 +08:00

Merge branch 'master' into dropdown-refresh

This commit is contained in:
Bartłomiej Dach 2021-10-13 23:53:14 +02:00
commit d2115824fa
No known key found for this signature in database
GPG Key ID: BCECCD4FA41F6497
41 changed files with 1373 additions and 184 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +1,2 @@
github: ppy
custom: https://osu.ppy.sh/home/support custom: https://osu.ppy.sh/home/support

View File

@ -79,9 +79,14 @@ jobs:
run: | run: |
# TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround. # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround.
# FIXME: Suppress warnings from templates project # FIXME: Suppress warnings from templates project
dotnet codefilesanity | while read -r line; do exit_code=0
echo "::warning::$line" while read -r line; do
done if [[ ! -z "$line" ]]; then
echo "::error::$line"
exit_code=1
fi
done <<< $(dotnet codefilesanity)
exit $exit_code
# Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded. # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded.
# - name: .NET Format (Dry Run) # - name: .NET Format (Dry Run)

View File

@ -52,7 +52,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1012.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2021.1013.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Transitive Dependencies"> <ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. --> <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -45,8 +45,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{ {
new Spinner new Spinner
{ {
Duration = 2000, Duration = 6000,
Position = OsuPlayfield.BASE_SIZE / 2 Position = OsuPlayfield.BASE_SIZE / 2,
} }
} }
}, },

View File

@ -30,6 +30,9 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
public class TestSceneSpinnerRotation : TestSceneOsuPlayer public class TestSceneSpinnerRotation : TestSceneOsuPlayer
{ {
private const double spinner_start_time = 100;
private const double spinner_duration = 6000;
[Resolved] [Resolved]
private AudioManager audioManager { get; set; } private AudioManager audioManager { get; set; }
@ -77,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests
double finalTrackerRotation = 0, trackerRotationTolerance = 0; double finalTrackerRotation = 0, trackerRotationTolerance = 0;
double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0;
addSeekStep(5000); addSeekStep(spinner_start_time + 5000);
AddStep("retrieve disc rotation", () => AddStep("retrieve disc rotation", () =>
{ {
finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; finalTrackerRotation = drawableSpinner.RotationTracker.Rotation;
@ -90,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}); });
AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation);
addSeekStep(2500); addSeekStep(spinner_start_time + 2500);
AddAssert("disc rotation rewound", AddAssert("disc rotation rewound",
// we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in.
// due to the exponential damping applied we're allowing a larger margin of error of about 10% // due to the exponential damping applied we're allowing a larger margin of error of about 10%
@ -102,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests
// cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error.
() => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100));
addSeekStep(5000); addSeekStep(spinner_start_time + 5000);
AddAssert("is disc rotation almost same", AddAssert("is disc rotation almost same",
() => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance));
AddAssert("is symbol rotation almost same", AddAssert("is symbol rotation almost same",
@ -140,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test] [Test]
public void TestSpinnerNormalBonusRewinding() public void TestSpinnerNormalBonusRewinding()
{ {
addSeekStep(1000); addSeekStep(spinner_start_time + 1000);
AddAssert("player score matching expected bonus score", () => AddAssert("player score matching expected bonus score", () =>
{ {
@ -201,24 +204,9 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0));
} }
private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay
{
Frames = scoreReplay
.Frames
.Cast<OsuReplayFrame>()
.Select(replayFrame =>
{
var adjustedTime = replayFrame.Time * rate;
return new OsuReplayFrame(adjustedTime, replayFrame.Position, replayFrame.Actions.ToArray());
})
.Cast<ReplayFrame>()
.ToList()
};
private void addSeekStep(double time) private void addSeekStep(double time)
{ {
AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time));
AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100));
} }
@ -241,7 +229,8 @@ namespace osu.Game.Rulesets.Osu.Tests
new Spinner new Spinner
{ {
Position = new Vector2(256, 192), Position = new Vector2(256, 192),
EndTime = 6000, StartTime = spinner_start_time,
Duration = spinner_duration
}, },
} }
}; };

View File

@ -0,0 +1,114 @@
// 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.Diagnostics;
using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Logging;
using osu.Game.Models;
using osu.Game.Stores;
#nullable enable
namespace osu.Game.Tests.Database
{
public class FileStoreTests : RealmTest
{
[Test]
public void TestImportFile()
{
RunTestWithRealm((realmFactory, storage) =>
{
var realm = realmFactory.Context;
var files = new RealmFileStore(realmFactory, storage);
var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 });
realm.Write(() => files.Add(testData, realm));
Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8"));
Assert.True(files.Storage.Exists(realm.All<RealmFile>().First().StoragePath));
});
}
[Test]
public void TestImportSameFileTwice()
{
RunTestWithRealm((realmFactory, storage) =>
{
var realm = realmFactory.Context;
var files = new RealmFileStore(realmFactory, storage);
var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 });
realm.Write(() => files.Add(testData, realm));
realm.Write(() => files.Add(testData, realm));
Assert.AreEqual(1, realm.All<RealmFile>().Count());
});
}
[Test]
public void TestDontPurgeReferenced()
{
RunTestWithRealm((realmFactory, storage) =>
{
var realm = realmFactory.Context;
var files = new RealmFileStore(realmFactory, storage);
var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm));
var timer = new Stopwatch();
timer.Start();
realm.Write(() =>
{
// attach the file to an arbitrary beatmap
var beatmapSet = CreateBeatmapSet(CreateRuleset());
beatmapSet.Files.Add(new RealmNamedFileUsage(file, "arbitrary.resource"));
realm.Add(beatmapSet);
});
Logger.Log($"Import complete at {timer.ElapsedMilliseconds}");
string path = file.StoragePath;
Assert.True(realm.All<RealmFile>().Any());
Assert.True(files.Storage.Exists(path));
files.Cleanup();
Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}");
Assert.True(realm.All<RealmFile>().Any());
Assert.True(file.IsValid);
Assert.True(files.Storage.Exists(path));
});
}
[Test]
public void TestPurgeUnreferenced()
{
RunTestWithRealm((realmFactory, storage) =>
{
var realm = realmFactory.Context;
var files = new RealmFileStore(realmFactory, storage);
var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm));
string path = file.StoragePath;
Assert.True(realm.All<RealmFile>().Any());
Assert.True(files.Storage.Exists(path));
files.Cleanup();
Assert.False(realm.All<RealmFile>().Any());
Assert.False(file.IsValid);
Assert.False(files.Storage.Exists(path));
});
}
}
}

View File

@ -1,3 +1,6 @@
// 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;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -0,0 +1,213 @@
// 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;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Models;
using Realms;
#nullable enable
namespace osu.Game.Tests.Database
{
public class RealmLiveTests : RealmTest
{
[Test]
public void TestLiveCastability()
{
RunTestWithRealm((realmFactory, _) =>
{
RealmLive<RealmBeatmap> beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive();
ILive<IBeatmapInfo> iBeatmap = beatmap;
Assert.AreEqual(0, iBeatmap.Value.Length);
});
}
[Test]
public void TestValueAccessWithOpenContext()
{
RunTestWithRealm((realmFactory, _) =>
{
RealmLive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>
{
using (var threadContext = realmFactory.CreateContext())
{
var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
liveBeatmap = beatmap.ToLive();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
Debug.Assert(liveBeatmap != null);
Task.Factory.StartNew(() =>
{
Assert.DoesNotThrow(() =>
{
using (realmFactory.CreateContext())
{
var resolved = liveBeatmap.Value;
Assert.IsTrue(resolved.Realm.IsClosed);
Assert.IsTrue(resolved.IsValid);
// can access properties without a crash.
Assert.IsFalse(resolved.Hidden);
}
});
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
});
}
[Test]
public void TestScopedReadWithoutContext()
{
RunTestWithRealm((realmFactory, _) =>
{
RealmLive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>
{
using (var threadContext = realmFactory.CreateContext())
{
var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
liveBeatmap = beatmap.ToLive();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
Debug.Assert(liveBeatmap != null);
Task.Factory.StartNew(() =>
{
liveBeatmap.PerformRead(beatmap =>
{
Assert.IsTrue(beatmap.IsValid);
Assert.IsFalse(beatmap.Hidden);
});
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
});
}
[Test]
public void TestScopedWriteWithoutContext()
{
RunTestWithRealm((realmFactory, _) =>
{
RealmLive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>
{
using (var threadContext = realmFactory.CreateContext())
{
var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
liveBeatmap = beatmap.ToLive();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
Debug.Assert(liveBeatmap != null);
Task.Factory.StartNew(() =>
{
liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; });
liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); });
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
});
}
[Test]
public void TestValueAccessWithoutOpenContextFails()
{
RunTestWithRealm((realmFactory, _) =>
{
RealmLive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>
{
using (var threadContext = realmFactory.CreateContext())
{
var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
liveBeatmap = beatmap.ToLive();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
Debug.Assert(liveBeatmap != null);
Task.Factory.StartNew(() =>
{
Assert.Throws<InvalidOperationException>(() =>
{
var unused = liveBeatmap.Value;
});
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
});
}
[Test]
public void TestLiveAssumptions()
{
RunTestWithRealm((realmFactory, _) =>
{
int changesTriggered = 0;
using (var updateThreadContext = realmFactory.CreateContext())
{
updateThreadContext.All<RealmBeatmap>().SubscribeForNotifications(gotChange);
RealmLive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>
{
using (var threadContext = realmFactory.CreateContext())
{
var ruleset = CreateRuleset();
var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
// add a second beatmap to ensure that a full refresh occurs below.
// not just a refresh from the resolved Live.
threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
liveBeatmap = beatmap.ToLive();
}
}, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
Debug.Assert(liveBeatmap != null);
// not yet seen by main context
Assert.AreEqual(0, updateThreadContext.All<RealmBeatmap>().Count());
Assert.AreEqual(0, changesTriggered);
var resolved = liveBeatmap.Value;
// retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point.
Assert.AreEqual(2, updateThreadContext.All<RealmBeatmap>().Count());
Assert.AreEqual(1, changesTriggered);
// even though the realm that this instance was resolved for was closed, it's still valid.
Assert.IsTrue(resolved.Realm.IsClosed);
Assert.IsTrue(resolved.IsValid);
// can access properties without a crash.
Assert.IsFalse(resolved.Hidden);
updateThreadContext.Write(r =>
{
// can use with the main context.
r.Remove(resolved);
});
}
void gotChange(IRealmCollection<RealmBeatmap> sender, ChangeSet changes, Exception error)
{
changesTriggered++;
}
});
}
}
}

View File

@ -5,10 +5,12 @@ using System;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Models;
#nullable enable #nullable enable
@ -70,6 +72,46 @@ namespace osu.Game.Tests.Database
} }
} }
protected static RealmBeatmapSet CreateBeatmapSet(RealmRuleset ruleset)
{
RealmFile createRealmFile() => new RealmFile { Hash = Guid.NewGuid().ToString().ComputeSHA2Hash() };
var metadata = new RealmBeatmapMetadata
{
Title = "My Love",
Artist = "Kuba Oms"
};
var beatmapSet = new RealmBeatmapSet
{
Beatmaps =
{
new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Easy", },
new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Normal", },
new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Hard", },
new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Insane", }
},
Files =
{
new RealmNamedFileUsage(createRealmFile(), "test [easy].osu"),
new RealmNamedFileUsage(createRealmFile(), "test [normal].osu"),
new RealmNamedFileUsage(createRealmFile(), "test [hard].osu"),
new RealmNamedFileUsage(createRealmFile(), "test [insane].osu"),
}
};
for (int i = 0; i < 8; i++)
beatmapSet.Files.Add(new RealmNamedFileUsage(createRealmFile(), $"hitsound{i}.mp3"));
foreach (var b in beatmapSet.Beatmaps)
b.BeatmapSet = beatmapSet;
return beatmapSet;
}
protected static RealmRuleset CreateRuleset() =>
new RealmRuleset(0, "osu!", "osu", true);
private class RealmTestGame : Framework.Game private class RealmTestGame : Framework.Game
{ {
public RealmTestGame(Func<Task> work) public RealmTestGame(Func<Task> work)

View File

@ -17,7 +17,7 @@ namespace osu.Game.Tests
protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false)
{ {
var osu = new TestOsuGameBase(withBeatmap); var osu = new TestOsuGameBase(withBeatmap);
Task.Run(() => host.Run(osu)) Task.Factory.StartNew(() => host.Run(osu), TaskCreationOptions.LongRunning)
.ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted);
waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time");

View File

@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
@ -18,16 +19,19 @@ namespace osu.Game.Tests.Visual.Audio
{ {
public class TestSceneAudioFilter : OsuTestScene public class TestSceneAudioFilter : OsuTestScene
{ {
private OsuSpriteText lowpassText; private OsuSpriteText lowPassText;
private AudioFilter lowpassFilter; private AudioFilter lowPassFilter;
private OsuSpriteText highpassText; private OsuSpriteText highPassText;
private AudioFilter highpassFilter; private AudioFilter highPassFilter;
private Track track; private Track track;
private WaveformTestBeatmap beatmap; private WaveformTestBeatmap beatmap;
private OsuSliderBar<int> lowPassSlider;
private OsuSliderBar<int> highPassSlider;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(AudioManager audio) private void load(AudioManager audio)
{ {
@ -38,53 +42,89 @@ namespace osu.Game.Tests.Visual.Audio
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
lowpassFilter = new AudioFilter(audio.TrackMixer), lowPassFilter = new AudioFilter(audio.TrackMixer),
highpassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass),
lowpassText = new OsuSpriteText lowPassText = new OsuSpriteText
{ {
Padding = new MarginPadding(20), Padding = new MarginPadding(20),
Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz", Text = $"Low Pass: {lowPassFilter.Cutoff}hz",
Font = new FontUsage(size: 40) Font = new FontUsage(size: 40)
}, },
new OsuSliderBar<int> lowPassSlider = new OsuSliderBar<int>
{ {
Width = 500, Width = 500,
Height = 50, Height = 50,
Padding = new MarginPadding(20), Padding = new MarginPadding(20),
Current = { BindTarget = lowpassFilter.Cutoff } Current = new BindableInt
{
MinValue = 0,
MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF,
}
}, },
highpassText = new OsuSpriteText highPassText = new OsuSpriteText
{ {
Padding = new MarginPadding(20), Padding = new MarginPadding(20),
Text = $"High Pass: {highpassFilter.Cutoff.Value}hz", Text = $"High Pass: {highPassFilter.Cutoff}hz",
Font = new FontUsage(size: 40) Font = new FontUsage(size: 40)
}, },
new OsuSliderBar<int> highPassSlider = new OsuSliderBar<int>
{ {
Width = 500, Width = 500,
Height = 50, Height = 50,
Padding = new MarginPadding(20), Padding = new MarginPadding(20),
Current = { BindTarget = highpassFilter.Cutoff } Current = new BindableInt
{
MinValue = 0,
MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF,
}
} }
} }
}); });
lowpassFilter.Cutoff.ValueChanged += e => lowpassText.Text = $"Low Pass: {e.NewValue}hz";
highpassFilter.Cutoff.ValueChanged += e => highpassText.Text = $"High Pass: {e.NewValue}hz"; lowPassSlider.Current.ValueChanged += e =>
{
lowPassText.Text = $"Low Pass: {e.NewValue}hz";
lowPassFilter.Cutoff = e.NewValue;
};
highPassSlider.Current.ValueChanged += e =>
{
highPassText.Text = $"High Pass: {e.NewValue}hz";
highPassFilter.Cutoff = e.NewValue;
};
} }
#region Overrides of Drawable
protected override void Update()
{
base.Update();
highPassSlider.Current.Value = highPassFilter.Cutoff;
lowPassSlider.Current.Value = lowPassFilter.Cutoff;
}
#endregion
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
{ {
AddStep("Play Track", () => track.Start()); AddStep("Play Track", () => track.Start());
AddStep("Reset filters", () =>
{
lowPassFilter.Cutoff = AudioFilter.MAX_LOWPASS_CUTOFF;
highPassFilter.Cutoff = 0;
});
waitTrackPlay(); waitTrackPlay();
} }
[Test] [Test]
public void TestLowPass() public void TestLowPassSweep()
{ {
AddStep("Filter Sweep", () => AddStep("Filter Sweep", () =>
{ {
lowpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic); .CutoffTo(0, 2000, Easing.OutCubic);
}); });
@ -92,7 +132,7 @@ namespace osu.Game.Tests.Visual.Audio
AddStep("Filter Sweep (reverse)", () => AddStep("Filter Sweep (reverse)", () =>
{ {
lowpassFilter.CutoffTo(0).Then() lowPassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
}); });
@ -101,11 +141,11 @@ namespace osu.Game.Tests.Visual.Audio
} }
[Test] [Test]
public void TestHighPass() public void TestHighPassSweep()
{ {
AddStep("Filter Sweep", () => AddStep("Filter Sweep", () =>
{ {
highpassFilter.CutoffTo(0).Then() highPassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
}); });
@ -113,7 +153,7 @@ namespace osu.Game.Tests.Visual.Audio
AddStep("Filter Sweep (reverse)", () => AddStep("Filter Sweep (reverse)", () =>
{ {
highpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() highPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic); .CutoffTo(0, 2000, Easing.OutCubic);
}); });

View File

@ -103,6 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay
checkFrameCount(0); checkFrameCount(0);
} }
[Test]
public void TestRatePreservedWhenTimeNotProgressing()
{
AddStep("set manual clock rate", () => manualClock.Rate = 1);
seekManualTo(5000);
createStabilityContainer();
checkRate(1);
seekManualTo(10000);
checkRate(1);
AddWaitStep("wait some", 3);
checkRate(1);
seekManualTo(5000);
checkRate(-1);
AddWaitStep("wait some", 3);
checkRate(-1);
seekManualTo(10000);
checkRate(1);
}
private const int max_frames_catchup = 50; private const int max_frames_catchup = 50;
private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () =>
@ -116,6 +140,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkFrameCount(int frames) => private void checkFrameCount(int frames) =>
AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames);
private void checkRate(double rate) =>
AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate);
public class ClockConsumingChild : CompositeDrawable public class ClockConsumingChild : CompositeDrawable
{ {
private readonly OsuSpriteText text; private readonly OsuSpriteText text;

View File

@ -90,8 +90,12 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest(() => CreateTest(() =>
{ {
AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true);
AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300);
// Fail occurs at 164ms with the provided beatmap.
// Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience.
AddStep("set storyboard duration to 0.6s", () => currentStoryboardDuration = 600);
}); });
AddUntilStep("wait for fail", () => Player.HasFailed); AddUntilStep("wait for fail", () => Player.HasFailed);
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible);

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.Navigation
{
public class TestSceneStartupImport : OsuGameTestScene
{
private string importFilename;
protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename });
public override void SetUpSteps()
{
AddStep("Prepare import beatmap", () => importFilename = TestResources.GetTestBeatmapForImport());
base.SetUpSteps();
}
[Test]
public void TestImportCreatedNotification()
{
AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType<ImportProgressNotification>().Count() == 1);
}
}
}

View File

@ -14,7 +14,7 @@ namespace osu.Game.Tournament.Tests.NonVisual
public static TournamentGameBase LoadTournament(GameHost host, TournamentGameBase tournament = null) public static TournamentGameBase LoadTournament(GameHost host, TournamentGameBase tournament = null)
{ {
tournament ??= new TournamentGameBase(); tournament ??= new TournamentGameBase();
Task.Run(() => host.Run(tournament)) Task.Factory.StartNew(() => host.Run(tournament), TaskCreationOptions.LongRunning)
.ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted);
WaitForOrAssert(() => tournament.IsLoaded, @"osu! failed to start in a reasonable amount of time"); WaitForOrAssert(() => tournament.IsLoaded, @"osu! failed to start in a reasonable amount of time");
return tournament; return tournament;

View File

@ -4,7 +4,6 @@
using System.Diagnostics; using System.Diagnostics;
using ManagedBass.Fx; using ManagedBass.Fx;
using osu.Framework.Audio.Mixing; using osu.Framework.Audio.Mixing;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
namespace osu.Game.Audio.Effects namespace osu.Game.Audio.Effects
@ -21,10 +20,25 @@ namespace osu.Game.Audio.Effects
private readonly BQFParameters filter; private readonly BQFParameters filter;
private readonly BQFType type; private readonly BQFType type;
private bool isAttached;
private int cutoff;
/// <summary> /// <summary>
/// The current cutoff of this filter. /// The cutoff frequency of this filter.
/// </summary> /// </summary>
public BindableNumber<int> Cutoff { get; } public int Cutoff
{
get => cutoff;
set
{
if (value == cutoff)
return;
cutoff = value;
updateFilter(cutoff);
}
}
/// <summary> /// <summary>
/// A Component that implements a BASS FX BiQuad Filter Effect. /// A Component that implements a BASS FX BiQuad Filter Effect.
@ -36,102 +50,96 @@ namespace osu.Game.Audio.Effects
this.mixer = mixer; this.mixer = mixer;
this.type = type; this.type = type;
int initialCutoff;
switch (type)
{
case BQFType.HighPass:
initialCutoff = 1;
break;
case BQFType.LowPass:
initialCutoff = MAX_LOWPASS_CUTOFF;
break;
default:
initialCutoff = 500; // A default that should ensure audio remains audible for other filters.
break;
}
Cutoff = new BindableNumber<int>(initialCutoff)
{
MinValue = 1,
MaxValue = MAX_LOWPASS_CUTOFF
};
filter = new BQFParameters filter = new BQFParameters
{ {
lFilter = type, lFilter = type,
fCenter = initialCutoff,
fBandwidth = 0, fBandwidth = 0,
fQ = 0.7f // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0)
fQ = 0.7f
}; };
// Don't start attached if this is low-pass or high-pass filter (as they have special auto-attach/detach logic) Cutoff = getInitialCutoff(type);
if (type != BQFType.LowPass && type != BQFType.HighPass)
attachFilter();
Cutoff.ValueChanged += updateFilter;
} }
private void attachFilter() private int getInitialCutoff(BQFType type)
{ {
Debug.Assert(!mixer.Effects.Contains(filter)); switch (type)
mixer.Effects.Add(filter); {
case BQFType.HighPass:
return 1;
case BQFType.LowPass:
return MAX_LOWPASS_CUTOFF;
default:
return 500; // A default that should ensure audio remains audible for other filters.
}
} }
private void detachFilter() private void updateFilter(int newValue)
{ {
Debug.Assert(mixer.Effects.Contains(filter)); switch (type)
mixer.Effects.Remove(filter);
}
private void updateFilter(ValueChangedEvent<int> cutoff)
{ {
case BQFType.LowPass:
// Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz.
if (type == BQFType.LowPass) if (newValue >= MAX_LOWPASS_CUTOFF)
{ {
if (cutoff.NewValue >= MAX_LOWPASS_CUTOFF) ensureDetached();
{
detachFilter();
return; return;
} }
if (cutoff.OldValue >= MAX_LOWPASS_CUTOFF && cutoff.NewValue < MAX_LOWPASS_CUTOFF) break;
attachFilter();
}
// Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz.
if (type == BQFType.HighPass) case BQFType.HighPass:
if (newValue <= 1)
{ {
if (cutoff.NewValue <= 1) ensureDetached();
{
detachFilter();
return; return;
} }
if (cutoff.OldValue <= 1 && cutoff.NewValue > 1) break;
attachFilter();
} }
ensureAttached();
var filterIndex = mixer.Effects.IndexOf(filter); var filterIndex = mixer.Effects.IndexOf(filter);
if (filterIndex < 0) return; if (filterIndex < 0) return;
if (mixer.Effects[filterIndex] is BQFParameters existingFilter) if (mixer.Effects[filterIndex] is BQFParameters existingFilter)
{ {
existingFilter.fCenter = cutoff.NewValue; existingFilter.fCenter = newValue;
// required to update effect with new parameters. // required to update effect with new parameters.
mixer.Effects[filterIndex] = existingFilter; mixer.Effects[filterIndex] = existingFilter;
} }
} }
private void ensureAttached()
{
if (isAttached)
return;
Debug.Assert(!mixer.Effects.Contains(filter));
mixer.Effects.Add(filter);
isAttached = true;
}
private void ensureDetached()
{
if (!isAttached)
return;
Debug.Assert(mixer.Effects.Contains(filter));
mixer.Effects.Remove(filter);
isAttached = false;
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
ensureDetached();
if (mixer.Effects.Contains(filter))
detachFilter();
} }
} }
} }

View File

@ -1,7 +1,6 @@
// 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. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Transforms; using osu.Framework.Graphics.Transforms;
@ -12,7 +11,7 @@ namespace osu.Game.Audio.Effects
/// <summary> /// <summary>
/// The filter cutoff. /// The filter cutoff.
/// </summary> /// </summary>
BindableNumber<int> Cutoff { get; } int Cutoff { get; set; }
} }
public static class FilterableAudioComponentExtensions public static class FilterableAudioComponentExtensions
@ -40,7 +39,7 @@ namespace osu.Game.Audio.Effects
public static TransformSequence<T> CutoffTo<T, TEasing>(this T component, int newCutoff, double duration, TEasing easing) public static TransformSequence<T> CutoffTo<T, TEasing>(this T component, int newCutoff, double duration, TEasing easing)
where T : class, ITransformableFilter, IDrawable where T : class, ITransformableFilter, IDrawable
where TEasing : IEasingFunction where TEasing : IEasingFunction
=> component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing); => component.TransformTo(nameof(component.Cutoff), newCutoff, duration, easing);
/// <summary> /// <summary>
/// Smoothly adjusts filter cutoff over time. /// Smoothly adjusts filter cutoff over time.
@ -49,6 +48,6 @@ namespace osu.Game.Audio.Effects
public static TransformSequence<T> CutoffTo<T, TEasing>(this TransformSequence<T> sequence, int newCutoff, double duration, TEasing easing) public static TransformSequence<T> CutoffTo<T, TEasing>(this TransformSequence<T> sequence, int newCutoff, double duration, TEasing easing)
where T : class, ITransformableFilter, IDrawable where T : class, ITransformableFilter, IDrawable
where TEasing : IEasingFunction where TEasing : IEasingFunction
=> sequence.Append(o => o.TransformBindableTo(o.Cutoff, newCutoff, duration, easing)); => sequence.Append(o => o.TransformTo(nameof(o.Cutoff), newCutoff, duration, easing));
} }
} }

View File

@ -116,7 +116,7 @@ namespace osu.Game.Database
/// <param name="paths">One or more archive locations on disk.</param> /// <param name="paths">One or more archive locations on disk.</param>
public Task Import(params string[] paths) public Task Import(params string[] paths)
{ {
var notification = new ProgressNotification { State = ProgressNotificationState.Active }; var notification = new ImportProgressNotification();
PostNotification?.Invoke(notification); PostNotification?.Invoke(notification);
@ -125,7 +125,7 @@ namespace osu.Game.Database
public Task Import(params ImportTask[] tasks) public Task Import(params ImportTask[] tasks)
{ {
var notification = new ProgressNotification { State = ProgressNotificationState.Active }; var notification = new ImportProgressNotification();
PostNotification?.Invoke(notification); PostNotification?.Invoke(notification);

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 System.Collections.Generic;
using osu.Game.Models;
#nullable enable
namespace osu.Game.Database
{
/// <summary>
/// A model that contains a list of files it is responsible for.
/// </summary>
public interface IHasRealmFiles
{
IList<RealmNamedFileUsage> Files { get; }
string Hash { get; set; }
}
}

View File

@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Models;
#nullable enable
namespace osu.Game.Database
{
/// <summary>
/// Represents a join model which gives a filename and scope to a <see cref="File"/>.
/// </summary>
public interface INamedFile
{
string Filename { get; set; }
RealmFile File { get; set; }
}
}

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.
using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
public class ImportProgressNotification : ProgressNotification
{
public ImportProgressNotification()
{
State = ProgressNotificationState.Active;
}
}
}

View File

@ -0,0 +1,111 @@
// 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.Threading;
using Realms;
#nullable enable
namespace osu.Game.Database
{
/// <summary>
/// Provides a method of working with realm objects over longer application lifetimes.
/// </summary>
/// <typeparam name="T">The underlying object type.</typeparam>
public class RealmLive<T> : ILive<T> where T : RealmObject, IHasGuidPrimaryKey
{
public Guid ID { get; }
private readonly SynchronizationContext? fetchedContext;
private readonly int fetchedThreadId;
/// <summary>
/// The original live data used to create this instance.
/// </summary>
private readonly T data;
/// <summary>
/// Construct a new instance of live realm data.
/// </summary>
/// <param name="data">The realm data.</param>
public RealmLive(T data)
{
this.data = data;
fetchedContext = SynchronizationContext.Current;
fetchedThreadId = Thread.CurrentThread.ManagedThreadId;
ID = data.ID;
}
/// <summary>
/// Perform a read operation on this live object.
/// </summary>
/// <param name="perform">The action to perform.</param>
public void PerformRead(Action<T> perform)
{
if (originalDataValid)
{
perform(data);
return;
}
using (var realm = Realm.GetInstance(data.Realm.Config))
perform(realm.Find<T>(ID));
}
/// <summary>
/// Perform a read operation on this live object.
/// </summary>
/// <param name="perform">The action to perform.</param>
public TReturn PerformRead<TReturn>(Func<T, TReturn> perform)
{
if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn)))
throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}.");
if (originalDataValid)
return perform(data);
using (var realm = Realm.GetInstance(data.Realm.Config))
return perform(realm.Find<T>(ID));
}
/// <summary>
/// Perform a write operation on this live object.
/// </summary>
/// <param name="perform">The action to perform.</param>
public void PerformWrite(Action<T> perform) =>
PerformRead(t =>
{
var transaction = t.Realm.BeginWrite();
perform(t);
transaction.Commit();
});
public T Value
{
get
{
if (originalDataValid)
return data;
T retrieved;
using (var realm = Realm.GetInstance(data.Realm.Config))
retrieved = realm.Find<T>(ID);
if (!retrieved.IsValid)
throw new InvalidOperationException("Attempted to access value without an open context");
return retrieved;
}
}
private bool originalDataValid => isCorrectThread && data.IsValid;
// this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72)
private bool isCorrectThread
=> (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId;
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using AutoMapper; using AutoMapper;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using Realms; using Realms;
@ -47,5 +48,17 @@ namespace osu.Game.Database
return mapper.Map<T>(item); return mapper.Map<T>(item);
} }
public static List<RealmLive<T>> ToLive<T>(this IEnumerable<T> realmList)
where T : RealmObject, IHasGuidPrimaryKey
{
return realmList.Select(l => new RealmLive<T>(l)).ToList();
}
public static RealmLive<T> ToLive<T>(this T realmObject)
where T : RealmObject, IHasGuidPrimaryKey
{
return new RealmLive<T>(realmObject);
}
} }
} }

View File

@ -0,0 +1,117 @@
// 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 JetBrains.Annotations;
using Newtonsoft.Json;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets;
using Realms;
#nullable enable
namespace osu.Game.Models
{
/// <summary>
/// A single beatmap difficulty.
/// </summary>
[ExcludeFromDynamicCompile]
[Serializable]
[MapTo("Beatmap")]
public class RealmBeatmap : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo
{
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public string DifficultyName { get; set; } = string.Empty;
public RealmRuleset Ruleset { get; set; } = null!;
public RealmBeatmapDifficulty Difficulty { get; set; } = null!;
public RealmBeatmapMetadata Metadata { get; set; } = null!;
public RealmBeatmapSet? BeatmapSet { get; set; }
public BeatmapSetOnlineStatus Status
{
get => (BeatmapSetOnlineStatus)StatusInt;
set => StatusInt = (int)value;
}
[MapTo(nameof(Status))]
public int StatusInt { get; set; }
public int? OnlineID { get; set; }
public double Length { get; set; }
public double BPM { get; set; }
public string Hash { get; set; } = string.Empty;
public double StarRating { get; set; }
public string MD5Hash { get; set; } = string.Empty;
[JsonIgnore]
public bool Hidden { get; set; }
public RealmBeatmap(RealmRuleset ruleset, RealmBeatmapDifficulty difficulty, RealmBeatmapMetadata metadata)
{
Ruleset = ruleset;
Difficulty = difficulty;
Metadata = metadata;
}
[UsedImplicitly]
private RealmBeatmap()
{
}
#region Properties we may not want persisted (but also maybe no harm?)
public double AudioLeadIn { get; set; }
public float StackLeniency { get; set; } = 0.7f;
public bool SpecialStyle { get; set; }
public bool LetterboxInBreaks { get; set; }
public bool WidescreenStoryboard { get; set; }
public bool EpilepsyWarning { get; set; }
public bool SamplesMatchPlaybackRate { get; set; }
public double DistanceSpacing { get; set; }
public int BeatDivisor { get; set; }
public int GridSize { get; set; }
public double TimelineZoom { get; set; }
#endregion
public bool AudioEquals(RealmBeatmap? other) => other != null
&& BeatmapSet != null
&& other.BeatmapSet != null
&& BeatmapSet.Hash == other.BeatmapSet.Hash
&& Metadata.AudioFile == other.Metadata.AudioFile;
public bool BackgroundEquals(RealmBeatmap? other) => other != null
&& BeatmapSet != null
&& other.BeatmapSet != null
&& BeatmapSet.Hash == other.BeatmapSet.Hash
&& Metadata.BackgroundFile == other.Metadata.BackgroundFile;
IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata;
IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet;
IRulesetInfo IBeatmapInfo.Ruleset => Ruleset;
IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => Difficulty;
}
}

View File

@ -0,0 +1,45 @@
// 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.Testing;
using osu.Game.Beatmaps;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
[MapTo("BeatmapDifficulty")]
public class RealmBeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo
{
public float DrainRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
public float CircleSize { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
public float OverallDifficulty { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
public float ApproachRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
public double SliderMultiplier { get; set; } = 1;
public double SliderTickRate { get; set; } = 1;
/// <summary>
/// Returns a shallow-clone of this <see cref="RealmBeatmapDifficulty"/>.
/// </summary>
public RealmBeatmapDifficulty Clone()
{
var diff = new RealmBeatmapDifficulty();
CopyTo(diff);
return diff;
}
public void CopyTo(RealmBeatmapDifficulty difficulty)
{
difficulty.ApproachRate = ApproachRate;
difficulty.DrainRate = DrainRate;
difficulty.CircleSize = CircleSize;
difficulty.OverallDifficulty = OverallDifficulty;
difficulty.SliderMultiplier = SliderMultiplier;
difficulty.SliderTickRate = SliderTickRate;
}
}
}

View File

@ -0,0 +1,45 @@
// 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 Newtonsoft.Json;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
[Serializable]
[MapTo("BeatmapMetadata")]
public class RealmBeatmapMetadata : RealmObject, IBeatmapMetadataInfo
{
public string Title { get; set; } = string.Empty;
[JsonProperty("title_unicode")]
public string TitleUnicode { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
[JsonProperty("artist_unicode")]
public string ArtistUnicode { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty; // eventually should be linked to a persisted User.
public string Source { get; set; } = string.Empty;
[JsonProperty(@"tags")]
public string Tags { get; set; } = string.Empty;
/// <summary>
/// The time in milliseconds to begin playing the track for preview purposes.
/// If -1, the track should begin playing at 40% of its length.
/// </summary>
public int PreviewTime { get; set; }
public string AudioFile { get; set; } = string.Empty;
public string BackgroundFile { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,78 @@
// 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 osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
[MapTo("BeatmapSet")]
public class RealmBeatmapSet : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable<RealmBeatmapSet>, IBeatmapSetInfo
{
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public int? OnlineID { get; set; }
public DateTimeOffset DateAdded { get; set; }
public IBeatmapMetadataInfo? Metadata => Beatmaps.FirstOrDefault()?.Metadata;
public IList<RealmBeatmap> Beatmaps { get; } = null!;
public IList<RealmNamedFileUsage> Files { get; } = null!;
public bool DeletePending { get; set; }
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Whether deleting this beatmap set should be prohibited (due to it being a system requirement to be present).
/// </summary>
public bool Protected { get; set; }
public double MaxStarDifficulty => Beatmaps.Max(b => b.StarRating);
public double MaxLength => Beatmaps.Max(b => b.Length);
public double MaxBPM => Beatmaps.Max(b => b.BPM);
/// <summary>
/// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null.
/// The path returned is relative to the user file storage.
/// </summary>
/// <param name="filename">The name of the file to get the storage path of.</param>
public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.StoragePath;
public override string ToString() => Metadata?.ToString() ?? base.ToString();
public bool Equals(RealmBeatmapSet? other)
{
if (other == null)
return false;
if (IsManaged && other.IsManaged)
return ID == other.ID;
if (OnlineID.HasValue && other.OnlineID.HasValue)
return OnlineID == other.OnlineID;
if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash))
return Hash == other.Hash;
return ReferenceEquals(this, other);
}
IEnumerable<IBeatmapInfo> IBeatmapSetInfo.Beatmaps => Beatmaps;
IEnumerable<INamedFileUsage> IBeatmapSetInfo.Files => Files;
}
}

View File

@ -0,0 +1,22 @@
// 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;
using osu.Framework.Testing;
using osu.Game.IO;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
[MapTo("File")]
public class RealmFile : RealmObject, IFileInfo
{
[PrimaryKey]
public string Hash { get; set; } = string.Empty;
public string StoragePath => Path.Combine(Hash.Remove(1), Hash.Remove(2), Hash);
}
}

View File

@ -0,0 +1,34 @@
// 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 JetBrains.Annotations;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.IO;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage
{
public RealmFile File { get; set; } = null!;
public string Filename { get; set; } = null!;
public RealmNamedFileUsage(RealmFile file, string filename)
{
File = file;
Filename = filename;
}
[UsedImplicitly]
private RealmNamedFileUsage()
{
}
IFileInfo INamedFileUsage.File => File;
}
}

View File

@ -0,0 +1,63 @@
// 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 JetBrains.Annotations;
using osu.Framework.Testing;
using osu.Game.Rulesets;
using Realms;
#nullable enable
namespace osu.Game.Models
{
[ExcludeFromDynamicCompile]
[MapTo("Ruleset")]
public class RealmRuleset : RealmObject, IEquatable<RealmRuleset>, IRulesetInfo
{
[PrimaryKey]
public string ShortName { get; set; } = string.Empty;
public int? OnlineID { get; set; }
public string Name { get; set; } = string.Empty;
public string InstantiationInfo { get; set; } = string.Empty;
public RealmRuleset(string shortName, string name, string instantiationInfo, int? onlineID = null)
{
ShortName = shortName;
Name = name;
InstantiationInfo = instantiationInfo;
OnlineID = onlineID;
}
[UsedImplicitly]
private RealmRuleset()
{
}
public RealmRuleset(int? onlineID, string name, string shortName, bool available)
{
OnlineID = onlineID;
Name = name;
ShortName = shortName;
Available = available;
}
public bool Available { get; set; }
public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo;
public override string ToString() => Name;
public RealmRuleset Clone() => new RealmRuleset
{
OnlineID = OnlineID,
Name = Name,
ShortName = ShortName,
InstantiationInfo = InstantiationInfo,
Available = Available
};
}
}

View File

@ -130,12 +130,6 @@ namespace osu.Game.Online.Rooms
set => MaxAttempts.Value = value; set => MaxAttempts.Value = value;
} }
/// <summary>
/// The position of this <see cref="Room"/> in the list. This is not read from or written to the API.
/// </summary>
[JsonIgnore]
public readonly Bindable<long> Position = new Bindable<long>(-1); // Todo: This does not need to exist.
public Room() public Room()
{ {
Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue));
@ -192,8 +186,6 @@ namespace osu.Game.Online.Rooms
RecentParticipants.Clear(); RecentParticipants.Clear();
RecentParticipants.AddRange(other.RecentParticipants); RecentParticipants.AddRange(other.RecentParticipants);
} }
Position.Value = other.Position.Value;
} }
public void RemoveExpiredPlaylistItems() public void RemoveExpiredPlaylistItems()

View File

@ -211,13 +211,6 @@ namespace osu.Game
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
if (args?.Length > 0)
{
var paths = args.Where(a => !a.StartsWith('-')).ToArray();
if (paths.Length > 0)
Task.Run(() => Import(paths));
}
dependencies.CacheAs(this); dependencies.CacheAs(this);
dependencies.Cache(SentryLogger); dependencies.Cache(SentryLogger);
@ -867,6 +860,19 @@ namespace osu.Game
{ {
if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); if (mode.NewValue != OverlayActivation.All) CloseAllOverlays();
}; };
// Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup.
handleStartupImport();
}
private void handleStartupImport()
{
if (args?.Length > 0)
{
var paths = args.Where(a => !a.StartsWith('-')).ToArray();
if (paths.Length > 0)
Task.Run(() => Import(paths));
}
} }
private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays) private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays)

View File

@ -55,7 +55,10 @@ namespace osu.Game.Rulesets.UI
/// <summary> /// <summary>
/// The current direction of playback to be exposed to frame stable children. /// The current direction of playback to be exposed to frame stable children.
/// </summary> /// </summary>
private int direction; /// <remarks>
/// Initially it is presumed that playback will proceed in the forward direction.
/// </remarks>
private int direction = 1;
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler)
@ -139,7 +142,9 @@ namespace osu.Game.Rulesets.UI
state = PlaybackState.NotValid; state = PlaybackState.NotValid;
} }
if (state == PlaybackState.Valid) // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously.
// this avoids spurious flips in direction from -1 to 1 during rewinds.
if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime)
direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; direction = proposedTime >= manualClock.CurrentTime ? 1 : -1;
double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime);

View File

@ -116,8 +116,6 @@ namespace osu.Game.Screens.OnlinePlay.Components
if (ignoredRooms.Contains(room.RoomID.Value.Value)) if (ignoredRooms.Contains(room.RoomID.Value.Value))
return; return;
room.Position.Value = -room.RoomID.Value.Value;
try try
{ {
foreach (var pi in room.Playlist) foreach (var pi in room.Playlist)

View File

@ -129,7 +129,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
private void updateSorting() private void updateSorting()
{ {
foreach (var room in roomFlow) foreach (var room in roomFlow)
roomFlow.SetLayoutPosition(room, room.Room.Position.Value); roomFlow.SetLayoutPosition(room, -(room.Room.RoomID.Value ?? 0));
} }
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)

View File

@ -22,7 +22,7 @@ namespace osu.Game.Screens.Play
{ {
/// <summary> /// <summary>
/// Manage the animation to be applied when a player fails. /// Manage the animation to be applied when a player fails.
/// Single file; automatically disposed after use. /// Single use and automatically disposed after use.
/// </summary> /// </summary>
public class FailAnimation : CompositeDrawable public class FailAnimation : CompositeDrawable
{ {

View File

@ -35,6 +35,8 @@ namespace osu.Game.Screens.Play
{ {
protected const float BACKGROUND_BLUR = 15; protected const float BACKGROUND_BLUR = 15;
private const double content_out_duration = 300;
public override bool HideOverlaysOnEnter => hideOverlays; public override bool HideOverlaysOnEnter => hideOverlays;
public override bool DisallowExternalBeatmapRulesetChanges => true; public override bool DisallowExternalBeatmapRulesetChanges => true;
@ -135,7 +137,9 @@ namespace osu.Game.Screens.Play
muteWarningShownOnce = sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce); muteWarningShownOnce = sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce);
batteryWarningShownOnce = sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce);
InternalChild = (content = new LogoTrackingContainer InternalChildren = new Drawable[]
{
(content = new LogoTrackingContainer
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -163,8 +167,9 @@ namespace osu.Game.Screens.Play
} }
}, },
idleTracker = new IdleTracker(750), idleTracker = new IdleTracker(750),
}),
lowPassFilter = new AudioFilter(audio.TrackMixer) lowPassFilter = new AudioFilter(audio.TrackMixer)
}); };
if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) if (Beatmap.Value.BeatmapInfo.EpilepsyWarning)
{ {
@ -195,7 +200,6 @@ namespace osu.Game.Screens.Play
epilepsyWarning.DimmableBackground = b; epilepsyWarning.DimmableBackground = b;
}); });
lowPassFilter.CutoffTo(500, 100, Easing.OutCubic);
Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
content.ScaleTo(0.7f); content.ScaleTo(0.7f);
@ -240,15 +244,15 @@ namespace osu.Game.Screens.Play
public override bool OnExiting(IScreen next) public override bool OnExiting(IScreen next)
{ {
cancelLoad(); cancelLoad();
contentOut();
content.ScaleTo(0.7f, 150, Easing.InQuint); // Ensure the screen doesn't expire until all the outwards fade operations have completed.
this.FadeOut(150); this.Delay(content_out_duration).FadeOut();
ApplyToBackground(b => b.IgnoreUserSettings.Value = true); ApplyToBackground(b => b.IgnoreUserSettings.Value = true);
BackgroundBrightnessReduction = false; BackgroundBrightnessReduction = false;
Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment);
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic);
return base.OnExiting(next); return base.OnExiting(next);
} }
@ -344,6 +348,7 @@ namespace osu.Game.Screens.Play
content.FadeInFromZero(400); content.FadeInFromZero(400);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint);
ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint));
} }
@ -353,8 +358,9 @@ namespace osu.Game.Screens.Play
// Ensure the logo is no longer tracking before we scale the content // Ensure the logo is no longer tracking before we scale the content
content.StopTracking(); content.StopTracking();
content.ScaleTo(0.7f, 300, Easing.InQuint); content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint);
content.FadeOut(250); content.FadeOut(content_out_duration, Easing.OutQuint);
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration);
} }
private void pushWhenLoaded() private void pushWhenLoaded()
@ -381,7 +387,7 @@ namespace osu.Game.Screens.Play
contentOut(); contentOut();
TransformSequence<PlayerLoader> pushSequence = this.Delay(250); TransformSequence<PlayerLoader> pushSequence = this.Delay(content_out_duration);
// only show if the warning was created (i.e. the beatmap needs it) // only show if the warning was created (i.e. the beatmap needs it)
// and this is not a restart of the map (the warning expires after first load). // and this is not a restart of the map (the warning expires after first load).
@ -400,6 +406,11 @@ namespace osu.Game.Screens.Play
}) })
.Delay(EpilepsyWarning.FADE_DURATION); .Delay(EpilepsyWarning.FADE_DURATION);
} }
else
{
// This goes hand-in-hand with the restoration of low pass filter in contentOut().
this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic);
}
pushSequence.Schedule(() => pushSequence.Schedule(() =>
{ {

View File

@ -0,0 +1,116 @@
// 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 osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Models;
using Realms;
#nullable enable
namespace osu.Game.Stores
{
/// <summary>
/// Handles the storing of files to the file system (and database) backing.
/// </summary>
[ExcludeFromDynamicCompile]
public class RealmFileStore
{
private readonly RealmContextFactory realmFactory;
public readonly IResourceStore<byte[]> Store;
public readonly Storage Storage;
public RealmFileStore(RealmContextFactory realmFactory, Storage storage)
{
this.realmFactory = realmFactory;
Storage = storage.GetStorageForDirectory(@"files");
Store = new StorageBackedResourceStore(Storage);
}
/// <summary>
/// Add a new file to the game-wide database, copying it to permanent storage if not already present.
/// </summary>
/// <param name="data">The file data stream.</param>
/// <param name="realm">The realm instance to add to. Should already be in a transaction.</param>
/// <returns></returns>
public RealmFile Add(Stream data, Realm realm)
{
string hash = data.ComputeSHA2Hash();
var existing = realm.Find<RealmFile>(hash);
var file = existing ?? new RealmFile { Hash = hash };
if (!checkFileExistsAndMatchesHash(file))
copyToStore(file, data);
if (!file.IsManaged)
realm.Add(file);
return file;
}
private void copyToStore(RealmFile file, Stream data)
{
data.Seek(0, SeekOrigin.Begin);
using (var output = Storage.GetStream(file.StoragePath, FileAccess.Write))
data.CopyTo(output);
data.Seek(0, SeekOrigin.Begin);
}
private bool checkFileExistsAndMatchesHash(RealmFile file)
{
string path = file.StoragePath;
// we may be re-adding a file to fix missing store entries.
if (!Storage.Exists(path))
return false;
// even if the file already exists, check the existing checksum for safety.
using (var stream = Storage.GetStream(path))
return stream.ComputeSHA2Hash() == file.Hash;
}
public void Cleanup()
{
var realm = realmFactory.Context;
// can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal.
using (var transaction = realm.BeginWrite())
{
// TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707)
var files = realm.All<RealmFile>().ToList();
foreach (var file in files)
{
if (file.BacklinksCount > 0)
continue;
try
{
Storage.Delete(file.StoragePath);
realm.Remove(file);
}
catch (Exception e)
{
Logger.Error(e, $@"Could not delete databased file {file.Hash}");
}
}
transaction.Commit();
}
}
}
}

View File

@ -78,9 +78,11 @@ namespace osu.Game.Tests.Visual
protected void CreateGame() protected void CreateGame()
{ {
AddGame(Game = new TestOsuGame(LocalStorage, API)); AddGame(Game = CreateTestGame());
} }
protected virtual TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API);
protected void PushAndConfirm(Func<Screen> newScreen) protected void PushAndConfirm(Func<Screen> newScreen)
{ {
Screen screen = null; Screen screen = null;
@ -135,7 +137,8 @@ namespace osu.Game.Tests.Visual
public new void PerformFromScreen(Action<IScreen> action, IEnumerable<Type> validScreens = null) => base.PerformFromScreen(action, validScreens); public new void PerformFromScreen(Action<IScreen> action, IEnumerable<Type> validScreens = null) => base.PerformFromScreen(action, validScreens);
public TestOsuGame(Storage storage, IAPIProvider api) public TestOsuGame(Storage storage, IAPIProvider api, string[] args = null)
: base(args)
{ {
Storage = storage; Storage = storage;
API = api; API = api;

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="10.6.0" /> <PackageReference Include="Realm" Version="10.6.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1012.0" /> <PackageReference Include="ppy.osu.Framework" Version="2021.1013.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
<PackageReference Include="Sentry" Version="3.9.4" /> <PackageReference Include="Sentry" Version="3.9.4" />
<PackageReference Include="SharpCompress" Version="0.29.0" /> <PackageReference Include="SharpCompress" Version="0.29.0" />

View File

@ -70,7 +70,7 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1012.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1013.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.1004.0" />
</ItemGroup> </ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) --> <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@ -93,7 +93,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1012.0" /> <PackageReference Include="ppy.osu.Framework" Version="2021.1013.0" />
<PackageReference Include="SharpCompress" Version="0.28.3" /> <PackageReference Include="SharpCompress" Version="0.28.3" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" /> <PackageReference Include="SharpRaven" Version="2.4.0" />