1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 08:07:24 +08:00

Merge pull request #19828 from peppy/no-gameplay-clock-gameplay-offset

Introduce `FramedBeatmapClock` (and use in gameplay flow)
This commit is contained in:
Dan Balasescu 2022-08-26 20:21:34 +09:00 committed by GitHub
commit 91e044542d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 440 additions and 258 deletions

View File

@ -52,7 +52,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.819.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.825.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. -->

View File

@ -84,12 +84,15 @@ namespace osu.Game.Tests.Gameplay
});
});
AddStep("reset clock", () => gameplayContainer.Start());
AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
AddUntilStep("sample played", () => sample.RequestedPlaying);
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
}
/// <summary>
/// Sample at 0ms, start time at 1000ms (so the sample should not be played).
/// </summary>
[Test]
public void TestSampleHasLifetimeEndWithInitialClockTime()
{
@ -104,12 +107,13 @@ namespace osu.Game.Tests.Gameplay
Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time)
{
StartTime = start_time,
Child = new FrameStabilityContainer
{
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
}
});
gameplayContainer.Reset(start_time);
});
AddStep("start time", () => gameplayContainer.Start());
@ -143,7 +147,7 @@ namespace osu.Game.Tests.Gameplay
});
});
AddStep("start", () => gameplayContainer.Start());
AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
AddUntilStep("sample played", () => sample.IsPlayed);
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);

View File

@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private LeadInPlayer player = null!;
private const double lenience_ms = 10;
private const double lenience_ms = 100;
private const double first_hit_object = 2170;

View File

@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(startTime: gameplay_start);
AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
}
/// <summary>
@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay
waitForPlayer();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
}
[Test]
@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
checkPaused(true);
AddAssert("time advanced", () => currentFrameStableTime > pausedTime);
AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime));
}
[Test]
@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(300);
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime > 30000);
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
}
[Test]

View File

@ -165,11 +165,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 40);
sendFrames(PLAYER_2_ID, 20);
checkPaused(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
waitUntilPaused(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
AddAssert("master clock still running", () => this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
checkPaused(PLAYER_1_ID, true);
waitUntilPaused(PLAYER_1_ID);
AddUntilStep("master clock paused", () => !this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
}
@ -181,13 +181,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Send frames for the other player, both should now start playing.
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
}
[Test]
@ -198,15 +198,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 1000);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Wait for the start delay seconds...
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
// Player 1 should start playing by itself, player 2 should remain paused.
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, true);
checkRunningInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
}
[Test]
@ -218,26 +218,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 20);
sendFrames(PLAYER_2_ID);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
// Eventually player 2 will pause, player 1 must remain running.
checkPaused(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
waitUntilPaused(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
// Eventually both players will run out of frames and should pause.
checkPaused(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
waitUntilPaused(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
// Send more frames for the second player. Both should be playing
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_2_ID, false);
checkPausedInstant(PLAYER_1_ID, false);
checkRunningInstant(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
}
[Test]
@ -249,16 +249,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 1000);
sendFrames(PLAYER_2_ID, 30);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
// Eventually player 2 will run out of frames and should pause.
checkPaused(PLAYER_2_ID, true);
waitUntilPaused(PLAYER_2_ID);
AddWaitStep("wait a few more frames", 10);
// Send more frames for player 2. It should unpause.
sendFrames(PLAYER_2_ID, 1000);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_2_ID);
// Player 2 should catch up to player 1 after unpausing.
waitForCatchup(PLAYER_2_ID);
@ -271,21 +271,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
// With no frames, the synchronisation state will be TooFarAhead.
// In this state, all players should be muted.
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, true);
sendFrames(PLAYER_1_ID);
// Send frames for both players, with more frames for player 2.
sendFrames(PLAYER_1_ID, 5);
sendFrames(PLAYER_2_ID, 20);
checkPaused(PLAYER_1_ID, false);
assertOneNotMuted();
checkPaused(PLAYER_1_ID, true);
// While both players are running, one of them should be un-muted.
waitUntilRunning(PLAYER_1_ID);
assertOnePlayerNotMuted();
// After player 1 runs out of frames, the un-muted player should always be player 2.
waitUntilPaused(PLAYER_1_ID);
waitUntilRunning(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, false);
sendFrames(PLAYER_1_ID, 100);
waitForCatchup(PLAYER_1_ID);
checkPaused(PLAYER_2_ID, true);
waitUntilPaused(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
@ -319,7 +326,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 300);
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
checkPaused(PLAYER_1_ID, false);
waitUntilRunning(PLAYER_1_ID);
sendFrames(PLAYER_2_ID, 300);
AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType<DrawableRuleset>().Single().FrameStableClock.CurrentTime > 30000);
@ -357,12 +364,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
/// <summary>
/// Tests spectating with a beatmap that has a high <see cref="BeatmapInfo.AudioLeadIn"/> value.
///
/// This test is not intended not to check the correct initial time value, but only to guard against
/// gameplay potentially getting stuck in a stopped state due to lead in time being present.
/// </summary>
[Test]
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
/// <summary>
/// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element).
///
/// This test is not intended not to check the correct initial time value, but only to guard against
/// gameplay potentially getting stuck in a stopped state due to lead in time being present.
/// </summary>
[Test]
public void TestIntroStoryboardElement() => testLeadIn(b =>
@ -384,10 +397,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded);
AddWaitStep("wait for progression", 3);
AddUntilStep("wait for clock running", () => getInstance(PLAYER_1_ID).SpectatorPlayerClock.IsRunning);
assertNotCatchingUp(PLAYER_1_ID);
assertRunning(PLAYER_1_ID);
waitUntilRunning(PLAYER_1_ID);
}
private void loadSpectateScreen(bool waitForPlayerLoad = true, Action<WorkingBeatmap>? applyToBeatmap = null)
@ -439,6 +452,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
/// <summary>
/// Send new frames on behalf of a user.
/// Frames will last for count * 100 milliseconds.
/// </summary>
private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count);
private void sendFrames(int[] userIds, int count = 10)
@ -450,30 +467,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
private void checkPaused(int userId, bool state)
=> AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning != state);
private void checkPausedInstant(int userId, bool state)
private void checkRunningInstant(int userId)
{
checkPaused(userId, state);
waitUntilRunning(userId);
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
}
private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType<PlayerArea>().Count(p => !p.Mute) == 1);
private void checkPausedInstant(int userId)
{
waitUntilPaused(userId);
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
}
private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType<PlayerArea>().Count(p => !p.Mute) == 1);
private void assertMuted(int userId, bool muted)
=> AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted);
=> AddAssert($"{nameof(assertMuted)}({userId}, {muted})", () => getInstance(userId).Mute == muted);
private void assertRunning(int userId)
=> AddAssert($"{userId} clock running", () => getInstance(userId).GameplayClock.IsRunning);
=> AddAssert($"{nameof(assertRunning)}({userId})", () => getInstance(userId).SpectatorPlayerClock.IsRunning);
private void waitUntilPaused(int userId)
=> AddUntilStep($"{nameof(waitUntilPaused)}({userId})", () => !getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning);
private void waitUntilRunning(int userId)
=> AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning);
private void assertNotCatchingUp(int userId)
=> AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp);
=> AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
private void waitForCatchup(int userId)
=> AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp);
=> AddUntilStep($"{nameof(waitForCatchup)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType<Player>().Single();

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 osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Screens.Play;
namespace osu.Game.Beatmaps
{
/// <summary>
/// A clock intended to be the single source-of-truth for beatmap timing.
///
/// It provides some functionality:
/// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets).
/// - Adjusts <see cref="Seek"/> operations to account for any applied offsets, seeking in raw "beatmap" time values.
/// - Exposes track length.
/// - Allows changing the source to a new track (for cases like editor track updating).
/// </summary>
public class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
{
private readonly bool applyOffsets;
/// <summary>
/// The length of the underlying beatmap track. Will default to 60 seconds if unavailable.
/// </summary>
public double TrackLength => Track.Length;
/// <summary>
/// The underlying beatmap track, if available.
/// </summary>
public Track Track { get; private set; } = new TrackVirtual(60000);
/// <summary>
/// The total frequency adjustment from pause transforms. Should eventually be handled in a better way.
/// </summary>
public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1);
private readonly OffsetCorrectionClock? userGlobalOffsetClock;
private readonly OffsetCorrectionClock? platformOffsetClock;
private readonly OffsetCorrectionClock? userBeatmapOffsetClock;
private readonly IFrameBasedClock finalClockSource;
private Bindable<double>? userAudioOffset;
private IDisposable? beatmapOffsetSubscription;
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public bool IsCoupled
{
get => decoupledClock.IsCoupled;
set => decoupledClock.IsCoupled = value;
}
public FramedBeatmapClock(bool applyOffsets = false)
{
this.applyOffsets = applyOffsets;
// A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting
// high precision times (on windows there's generally only 5-10ms reporting intervals, as an example).
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true };
if (applyOffsets)
{
// Audio timings in general with newer BASS versions don't match stable.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// User global offset (set in settings) should also be applied.
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust);
// User per-beatmap offset will be applied to this final clock.
finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust);
}
else
{
finalClockSource = decoupledClock;
}
}
protected override void LoadComplete()
{
base.LoadComplete();
if (applyOffsets)
{
Debug.Assert(userBeatmapOffsetClock != null);
Debug.Assert(userGlobalOffsetClock != null);
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
r => r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID)?.UserSettings,
settings => settings.Offset,
val =>
{
userBeatmapOffsetClock.Offset = val;
});
}
}
protected override void Update()
{
base.Update();
finalClockSource.ProcessFrame();
}
private double totalAppliedOffset
{
get
{
if (!applyOffsets)
return 0;
Debug.Assert(userGlobalOffsetClock != null);
Debug.Assert(userBeatmapOffsetClock != null);
Debug.Assert(platformOffsetClock != null);
return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
}
}
#region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock.
public void ChangeSource(IClock? source)
{
Track = source as Track ?? new TrackVirtual(60000);
decoupledClock.ChangeSource(source);
}
public IClock? Source => decoupledClock.Source;
public void Reset()
{
decoupledClock.Reset();
finalClockSource.ProcessFrame();
}
public void Start()
{
decoupledClock.Start();
finalClockSource.ProcessFrame();
}
public void Stop()
{
decoupledClock.Stop();
finalClockSource.ProcessFrame();
}
public bool Seek(double position)
{
bool success = decoupledClock.Seek(position - totalAppliedOffset);
finalClockSource.ProcessFrame();
return success;
}
public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments();
public double Rate
{
get => decoupledClock.Rate;
set => decoupledClock.Rate = value;
}
#endregion
#region Delegation of IFrameBasedClock to clock with all offsets applied
public double CurrentTime => finalClockSource.CurrentTime;
public bool IsRunning => finalClockSource.IsRunning;
public void ProcessFrame()
{
// Noop to ensure an external consumer doesn't process the internal clock an extra time.
}
public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime;
public double FramesPerSecond => finalClockSource.FramesPerSecond;
public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo;
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapOffsetSubscription?.Dispose();
}
}
}

View File

@ -281,7 +281,7 @@ namespace osu.Game.Rulesets.UI
}
}
public double? StartTime => parentGameplayClock?.StartTime;
public double StartTime => parentGameplayClock?.StartTime ?? 0;
public IEnumerable<double> NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<double>();

View File

@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
=> new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time };
{
var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart);
masterGameplayClockContainer.Reset(editorState.Time);
return masterGameplayClockContainer;
}
protected override void LoadComplete()
{

View File

@ -132,7 +132,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}, _ =>
{
foreach (var instance in instances)
leaderboard.AddClock(instance.UserId, instance.GameplayClock);
leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardFlow.Insert(0, leaderboard);
@ -163,10 +163,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
base.Update();
if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
{
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime))
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock))
.OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime))
.FirstOrDefault();
foreach (var instance in instances)
@ -187,8 +187,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
.DefaultIfEmpty(0)
.Min();
masterClockContainer.StartTime = startTime;
masterClockContainer.Reset(true);
masterClockContainer.Reset(startTime, true);
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
@ -216,7 +215,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemoveManagedClock(instance.GameplayClock);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
}
public override bool OnBackButton()

View File

@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public readonly int UserId;
/// <summary>
/// The <see cref="SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// The <see cref="Spectate.SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// </summary>
public readonly SpectatorPlayerClock GameplayClock;
public readonly SpectatorPlayerClock SpectatorPlayerClock;
/// <summary>
/// The currently-loaded score.
@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public PlayerArea(int userId, SpectatorPlayerClock clock)
{
UserId = userId;
GameplayClock = clock;
SpectatorPlayerClock = clock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@ -95,7 +95,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
{
var player = new MultiSpectatorPlayer(Score, GameplayClock);
var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock);
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
return player;
}));

View File

@ -77,7 +77,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
if (IsRunning)
{
double elapsedSource = masterClock.ElapsedFrameTime;
// When in catch-up mode, the source is usually not running.
// In such a case, its elapsed time may be zero, which would cause catch-up to get stuck.
// To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway.
// Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times.
double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16);
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;

View File

@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
namespace osu.Game.Screens.Play
{
/// <summary>
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
/// </summary>
[Cached(typeof(IGameplayClock))]
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
{
/// <summary>
@ -36,119 +38,137 @@ namespace osu.Game.Screens.Play
/// <summary>
/// The time from which the clock should start. Will be seeked to on calling <see cref="Reset"/>.
/// Can be adjusted by calling <see cref="Reset"/> with a time value.
/// </summary>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
public double? StartTime { get; set; }
public double StartTime { get; protected set; }
public virtual IEnumerable<double> NonGameplayAdjustments => Enumerable.Empty<double>();
/// <summary>
/// The final clock which is exposed to gameplay components.
/// </summary>
protected IFrameBasedClock FramedClock { get; private set; }
private readonly BindableBool isPaused = new BindableBool(true);
/// <summary>
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
/// This is the final source exposed to gameplay components <see cref="IGameplayClock"/> via delegation in this class.
/// </summary>
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
protected readonly FramedBeatmapClock GameplayClock;
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
/// <summary>
/// Creates a new <see cref="GameplayClockContainer"/>.
/// </summary>
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
public GameplayClockContainer(IClock sourceClock)
/// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param>
public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false)
{
SourceClock = sourceClock;
RelativeSizeAxes = Axes.Both;
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
IsPaused.BindValueChanged(OnIsPausedChanged);
// this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
FramedClock = new FramedClock();
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
FramedClock = CreateGameplayClock(decoupledClock);
dependencies.CacheAs<IGameplayClock>(this);
return dependencies;
InternalChildren = new Drawable[]
{
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
Content
};
}
/// <summary>
/// Starts gameplay.
/// Starts gameplay and marks un-paused state.
/// </summary>
public virtual void Start()
public void Start()
{
ensureSourceClockSet();
if (!decoupledClock.IsRunning)
{
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
// This accounts for the clock source potentially taking time to enter a completely stopped state
Seek(FramedClock.CurrentTime);
decoupledClock.Start();
}
if (!isPaused.Value)
return;
isPaused.Value = false;
ensureSourceClockSet();
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
// This accounts for the clock source potentially taking time to enter a completely stopped state
Seek(GameplayClock.CurrentTime);
// The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time.
// Because we generally update our own current time quicker than children can query it (via Start/Seek/Update),
// this means that the first frame ever exposed to children may have a non-zero current time.
//
// If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer)
// they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly
// if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample).
//
// By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing
// then to progress with a correct locally calculated elapsed time.
SchedulerAfterChildren.Add(() =>
{
if (isPaused.Value)
return;
StartGameplayClock();
});
}
/// <summary>
/// Seek to a specific time in gameplay.
/// </summary>
/// <param name="time">The destination time to seek to.</param>
public virtual void Seek(double time)
public void Seek(double time)
{
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
decoupledClock.Seek(time);
// Manually process to make sure the gameplay clock is correctly updated after a seek.
FramedClock.ProcessFrame();
GameplayClock.Seek(time);
OnSeek?.Invoke();
}
/// <summary>
/// Stops gameplay.
/// Stops gameplay and marks paused state.
/// </summary>
public void Stop() => isPaused.Value = true;
public void Stop()
{
if (isPaused.Value)
return;
isPaused.Value = true;
StopGameplayClock();
}
protected virtual void StartGameplayClock() => GameplayClock.Start();
protected virtual void StopGameplayClock() => GameplayClock.Stop();
/// <summary>
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
/// </summary>
/// <param name="time">The time to seek to on resetting. If <c>null</c>, the existing <see cref="StartTime"/> will be used.</param>
/// <param name="startClock">Whether to start the clock immediately, if not already started.</param>
public void Reset(bool startClock = false)
public void Reset(double? time = null, bool startClock = false)
{
// Manually stop the source in order to not affect the IsPaused state.
decoupledClock.Stop();
bool wasPaused = isPaused.Value;
if (!IsPaused.Value || startClock)
Start();
Stop();
ensureSourceClockSet();
Seek(StartTime ?? 0);
if (time != null)
StartTime = time.Value;
Seek(StartTime);
if (!wasPaused || startClock)
Start();
}
/// <summary>
/// Changes the source clock.
/// </summary>
/// <param name="sourceClock">The new source.</param>
protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock);
/// <summary>
/// Ensures that the <see cref="decoupledClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// Ensures that the <see cref="GameplayClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
@ -156,40 +176,10 @@ namespace osu.Game.Screens.Play
/// </summary>
private void ensureSourceClockSet()
{
if (decoupledClock.Source == null)
if (GameplayClock.Source == null)
ChangeSource(SourceClock);
}
protected override void Update()
{
if (!IsPaused.Value)
FramedClock.ProcessFrame();
base.Update();
}
/// <summary>
/// Invoked when the value of <see cref="IsPaused"/> is changed to start or stop the <see cref="decoupledClock"/> clock.
/// </summary>
/// <param name="isPaused">Whether the clock should now be paused.</param>
protected virtual void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
{
if (isPaused.NewValue)
decoupledClock.Stop();
else
decoupledClock.Start();
}
/// <summary>
/// Creates the final <see cref="FramedClock"/> which is exposed via DI to be used by gameplay components.
/// </summary>
/// <remarks>
/// Any intermediate clocks such as platform offsets should be applied here.
/// </remarks>
/// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param>
/// <returns>The final <see cref="FramedClock"/>.</returns>
protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source;
#region IAdjustableClock
bool IAdjustableClock.Seek(double position)
@ -204,15 +194,15 @@ namespace osu.Game.Screens.Play
double IAdjustableClock.Rate
{
get => FramedClock.Rate;
get => GameplayClock.Rate;
set => throw new NotSupportedException();
}
public double Rate => FramedClock.Rate;
public double Rate => GameplayClock.Rate;
public double CurrentTime => FramedClock.CurrentTime;
public double CurrentTime => GameplayClock.CurrentTime;
public bool IsRunning => FramedClock.IsRunning;
public bool IsRunning => GameplayClock.IsRunning;
#endregion
@ -221,11 +211,11 @@ namespace osu.Game.Screens.Play
// Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
}
public double ElapsedFrameTime => FramedClock.ElapsedFrameTime;
public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime;
public double FramesPerSecond => FramedClock.FramesPerSecond;
public double FramesPerSecond => GameplayClock.FramesPerSecond;
public FrameTimeInfo TimeInfo => FramedClock.TimeInfo;
public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo;
public double TrueGameplayRate
{

View File

@ -82,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
if (isInIntro)
{
double introStartTime = GameplayClock.StartTime ?? 0;
double introStartTime = GameplayClock.StartTime;
double introOffsetCurrent = currentTime - introStartTime;
double introDuration = FirstHitTime - introStartTime;

View File

@ -19,10 +19,10 @@ namespace osu.Game.Screens.Play
/// The time from which the clock should start. Will be seeked to on calling <see cref="GameplayClockContainer.Reset"/>.
/// </summary>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
double? StartTime { get; }
double StartTime { get; }
/// <summary>
/// All adjustments applied to this clock which don't come from gameplay or mods.

View File

@ -4,8 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
@ -13,8 +11,6 @@ using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
namespace osu.Game.Screens.Play
{
@ -43,28 +39,10 @@ namespace osu.Game.Screens.Play
Precision = 0.1,
};
private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock.
private readonly WorkingBeatmap beatmap;
private OffsetCorrectionClock userGlobalOffsetClock = null!;
private OffsetCorrectionClock userBeatmapOffsetClock = null!;
private OffsetCorrectionClock platformOffsetClock = null!;
private Bindable<double> userAudioOffset = null!;
private IDisposable? beatmapOffsetSubscription;
private readonly double skipTargetTime;
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private readonly List<Bindable<double>> nonGameplayAdjustments = new List<Bindable<double>>();
public override IEnumerable<double> NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
@ -75,32 +53,12 @@ namespace osu.Game.Screens.Play
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
: base(beatmap.Track)
: base(beatmap.Track, true)
{
this.beatmap = beatmap;
this.skipTargetTime = skipTargetTime;
}
protected override void LoadComplete()
{
base.LoadComplete();
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
r => r.Find<BeatmapInfo>(beatmap.BeatmapInfo.ID)?.UserSettings,
settings => settings.Offset,
val => userBeatmapOffsetClock.Offset = val);
// Reset may have been called externally before LoadComplete.
// If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here.
bool isStarted = !IsPaused.Value;
// If a custom start time was not specified, calculate the best value to use.
StartTime ??= findEarliestStartTime();
Reset(startClock: isStarted);
StartTime = findEarliestStartTime();
}
private double findEarliestStartTime()
@ -126,54 +84,49 @@ namespace osu.Game.Screens.Play
return time;
}
protected override void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
protected override void StopGameplayClock()
{
if (IsLoaded)
{
// During normal operation, the source is stopped after performing a frequency ramp.
if (isPaused.NewValue)
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
if (IsPaused.Value == isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
});
}
else
this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
if (IsPaused.Value)
base.StopGameplayClock();
});
}
else
{
if (isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
base.StopGameplayClock();
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1;
GameplayClock.ExternalPauseFrequencyAdjust.Value = 0;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
FramedClock.ProcessFrame();
GameplayClock.ProcessFrame();
}
}
public override void Start()
protected override void StartGameplayClock()
{
addSourceClockAdjustments();
base.Start();
}
/// <summary>
/// Seek to a specific time in gameplay.
/// </summary>
/// <remarks>
/// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track).
/// </remarks>
/// <param name="time">The destination time to seek to.</param>
public override void Seek(double time)
{
// remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track.
// we may want to consider reversing the application of offsets in the future as it may feel more correct.
base.Seek(time - totalAppliedOffset);
base.StartGameplayClock();
if (IsLoaded)
{
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In);
}
else
{
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
GameplayClock.ExternalPauseFrequencyAdjust.Value = 1;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
GameplayClock.ProcessFrame();
}
}
/// <summary>
@ -181,29 +134,18 @@ namespace osu.Game.Screens.Play
/// </summary>
public void Skip()
{
if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
return;
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
if (FramedClock.CurrentTime < 0 && skipTarget > 6000)
if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
// double skip exception for storyboards with very long intros
skipTarget = 0;
Seek(skipTarget);
}
protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source)
{
// Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// the final usable gameplay clock with user-set offsets applied.
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust);
return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust);
}
/// <summary>
/// Changes the backing clock to avoid using the originally provided track.
/// </summary>
@ -224,10 +166,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Add(pauseFreqAdjust);
nonGameplayAdjustments.Add(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
@ -241,10 +183,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Remove(pauseFreqAdjust);
nonGameplayAdjustments.Remove(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
@ -253,7 +195,6 @@ namespace osu.Game.Screens.Play
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapOffsetSubscription?.Dispose();
removeSourceClockAdjustments();
}

View File

@ -640,8 +640,7 @@ namespace osu.Game.Screens.Play
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
DrawableRuleset.FrameStablePlayback = false;
GameplayClockContainer.StartTime = time;
GameplayClockContainer.Reset();
GameplayClockContainer.Reset(time);
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
@ -1012,7 +1011,7 @@ namespace osu.Game.Screens.Play
if (GameplayClockContainer.IsRunning)
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
GameplayClockContainer.Reset(true);
GameplayClockContainer.Reset(startClock: true);
}
public override void OnSuspending(ScreenTransitionEvent e)

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.15.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
<PackageReference Include="Sentry" Version="3.20.1" />
<PackageReference Include="SharpCompress" Version="0.32.2" />

View File

@ -61,7 +61,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.819.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.825.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
@ -84,7 +84,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
<PackageReference Include="SharpCompress" Version="0.32.1" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />