Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ protected void UpdateUserStatesRandomly()
break;
}

spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }));
spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }, null));
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions osu.Game/Online/Spectator/CompleteReplayRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using MessagePack;

namespace osu.Game.Online.Spectator
{
/// <summary>
/// Sent by the server to retrieve frame bundles that the client sent but never reached the server, if any.
/// </summary>
/// <param name="ScoreTokenId">The ID of the score token associated with the score with missing bundles.</param>
/// <param name="FrameBundleSequenceNumbers">The sequence numbers of frame bundles that the server never received.</param>
[Serializable]
[MessagePackObject]
public record CompleteReplayRequest(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I got in the habit of doing with bancho/stable is prefixing these kinds of calls with Client or Server, because I guarantee we are going to end up with Request objects being fired in both directions sooner or later.

Thoughts?

ServerCompleteReplayRequest
ClientCompleteReplayResponse

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the fact that no other operation between client and spectator server uses this convention, I do not have any particular opinions on this proposal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until now we haven't had any classes with Request / Response pairing, so I'm not sure it would suit there. Specifically important for when Request/Response is involved IMHO, because until now the client is always the Requester when using these terms (aka web requests).

But we could potentially prefix other classes where relevant.

[property: Key(0)] long ScoreTokenId,
[property: Key(1)] IEnumerable<long> FrameBundleSequenceNumbers
);
}
22 changes: 22 additions & 0 deletions osu.Game/Online/Spectator/CompleteReplayResponse.cs
Original file line number Diff line number Diff line change
@@ -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;
using System.Collections.Generic;
using MessagePack;

namespace osu.Game.Online.Spectator
{
/// <summary>
/// Response to <see cref="CompleteReplayRequest"/>.
/// </summary>
/// <param name="FrameBundles">
/// The frame bundles requested by sequence number in the corresponding <see cref="CompleteReplayRequest"/>.
/// Can be blank (contain no frames) if the client does not have the frames available any more.
/// </param>
[Serializable]
[MessagePackObject]
public record CompleteReplayResponse(
[property: Key(0)] IEnumerable<FrameDataBundle> FrameBundles
);
}
10 changes: 9 additions & 1 deletion osu.Game/Online/Spectator/FrameDataBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,25 @@ public class FrameDataBundle
[Key(1)]
public IList<LegacyReplayFrame> Frames { get; set; }

/// <summary>
/// The sequence number of this frame bundle.
/// Used to determine ordering of frame bundles, and for server-side checks that server received all frame bundles it was supposed to.
/// </summary>
[Key(2)]
public long? SequenceNumber { get; set; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have ReceivedTime in FrameHeader, which ended up never being used, a bit weird.

Anyway, any reason this is in the bundle and not the header? I'm not sure what the differentiation we have as to what goes in either, but curious if you have reasoning for putting it here specifically.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have ReceivedTime in FrameHeader, which ended up never being used, a bit weird.

No idea what this is, as best I can tell it's completely dead.

Anyway, any reason this is in the bundle and not the header? I'm not sure what the differentiation we have as to what goes in either, but curious if you have reasoning for putting it here specifically.

There is no reason. Putting it on the bundle makes the server side a touch less painful because it removes one level of property scraping but that's not a real reason.


public FrameDataBundle(ScoreInfo score, ScoreProcessor scoreProcessor, IList<LegacyReplayFrame> frames)
{
Frames = frames;
Header = new FrameHeader(score, scoreProcessor.GetScoreProcessorStatistics());
}

[JsonConstructor]
public FrameDataBundle(FrameHeader header, IList<LegacyReplayFrame> frames)
public FrameDataBundle(FrameHeader header, IList<LegacyReplayFrame> frames, long? sequenceNumber)
{
Header = header;
Frames = frames;
SequenceNumber = sequenceNumber;
}
}
}
7 changes: 7 additions & 0 deletions osu.Game/Online/Spectator/ISpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,12 @@ public interface ISpectatorClient : IStatefulUserHubClient
/// </summary>
/// <param name="userId">The ID of the user who ended watching.</param>
Task UserEndedWatching(int userId);

/// <summary>
/// Called by the server in response to <see cref="ISpectatorServer.EndPlaySession"/>.
/// Using data provided in <see cref="SpectatorState"/>, this gives the server a last chance to retrieve any <see cref="FrameDataBundle"/>s
/// that it may not have received due to connection drop-outs or similar.
/// </summary>
Task<CompleteReplayResponse> CompleteReplay(CompleteReplayRequest request);
}
}
1 change: 1 addition & 0 deletions osu.Game/Online/Spectator/OnlineSpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private void load(IAPIProvider api)
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
connection.On<CompleteReplayRequest, CompleteReplayResponse>(nameof(ISpectatorClient.CompleteReplay), ((ISpectatorClient)this).CompleteReplay);
connection.On<int, long>(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed);
connection.On<SpectatorUser[]>(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching);
connection.On<int>(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching);
Expand Down
35 changes: 34 additions & 1 deletion osu.Game/Online/Spectator/SpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
Expand Down Expand Up @@ -89,8 +90,10 @@ public abstract partial class SpectatorClient : Component, ISpectatorClient
private Score? currentScore;
private long? currentScoreToken;
private ScoreProcessor? currentScoreProcessor;
private long currentFrameBundleSequenceNumber;

private readonly Queue<FrameDataBundle> pendingFrameBundles = new Queue<FrameDataBundle>();
private readonly Dictionary<long, List<FrameDataBundle>> allFrameBundles = new Dictionary<long, List<FrameDataBundle>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder how big this will grow during a multi-hour marathon beatmap 🤔. Might be worth calculating on paper.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Messages can be up to 32 KB in size by default. Not yet sure how this translates to actual usage, will investigate.

@bdach bdach Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Back of napkin estimations are not great.

frameBundle: 459 B
maxMessageSize: 32 * 1024 B
bundlesPerSecond: 5

maxMessageSize / (frameBundle * bundlesPerSecond) = 14,28 [seconds]

Listening to suggestions what to do about it.

316 of those 459 bytes are the actual replay frames, so even dropping the frame headers does not help here much.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd chunk the responses client side to a sane number, for sure.

But also I wasn't even thinking about max message size (so good you were I guess?). Was more about client side memory usage for very long maps. But 128 kb per minute sounds not too bad.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd chunk the responses client side to a sane number, for sure.

I don't understand what this means. Please elaborate.

Half of the appeal of this solution was that this was a single-shot request that doesn't need retrying. If suddenly I have to split this single-shot request into however many, each of which can fail (what happens when any one of them does?), this is going to get very complex very quick.

I'd sooner entertain solutions like having the client send the .osr across or similar. Maybe that at least can fit into a reasonably small size.


private readonly List<LegacyReplayFrame> pendingFrames = new List<LegacyReplayFrame>();

Expand Down Expand Up @@ -284,6 +287,8 @@ public void EndPlaying(GameplayState state)
if (pendingFrames.Count > 0)
purgePendingFrames();

currentState.LastFrameBundleSequenceNumber = currentFrameBundleSequenceNumber;

clearScoreState();

if (state.HasPassed)
Expand All @@ -305,6 +310,9 @@ private void setStateForScore(long? scoreToken, GameplayState state, Score score
currentScore = score;
currentScoreToken = scoreToken;
currentScoreProcessor = state.ScoreProcessor;
currentFrameBundleSequenceNumber = 0;
if (scoreToken != null)
allFrameBundles[scoreToken.Value] = new List<FrameDataBundle>();
}

private void clearScoreState()
Expand All @@ -315,6 +323,22 @@ private void clearScoreState()
currentScore = null;
currentScoreProcessor = null;
currentScoreToken = null;
currentFrameBundleSequenceNumber = 0;
}

public Task<CompleteReplayResponse> CompleteReplay(CompleteReplayRequest completeReplayRequest)
{
if (!allFrameBundles.Remove(completeReplayRequest.ScoreTokenId, out var frameBundlesForScoreToken))

@peppy peppy Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You touched on this in the OP, but I fear that this is not enough in terms of cleanup logic.

If a user reconnects after a long time being disconnected and the server has forgotten about the user's pending replay data, it's going to remain in the client dictionary until restart, correct?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user reconnects after a long time being disconnected and the server has forgotten about the user's pending replay data, it's going to remain in the client dictionary until restart, correct?

Correct. I have no opinion on how to handle that as choosing an expiry mechanism is highly subjective.

return Task.FromResult(new CompleteReplayResponse([]));

var sequenceNumberSet = completeReplayRequest.FrameBundleSequenceNumbers.ToHashSet();
if (sequenceNumberSet.Count == 0)
return Task.FromResult(new CompleteReplayResponse([]));

var bundles = frameBundlesForScoreToken.Where(b => b.SequenceNumber != null && sequenceNumberSet.Contains(b.SequenceNumber.Value))
.OrderBy(b => b.SequenceNumber)
.ToArray();
return Task.FromResult(new CompleteReplayResponse(bundles));
}

public virtual void WatchUser(int userId)
Expand Down Expand Up @@ -389,7 +413,16 @@ private void purgePendingFrames()
Debug.Assert(currentScoreProcessor != null);

var frames = pendingFrames.ToArray();
var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames);
var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames)
{
SequenceNumber = Interlocked.Increment(ref currentFrameBundleSequenceNumber)
};

if (currentScoreToken != null)
{
Debug.Assert(allFrameBundles.ContainsKey(currentScoreToken.Value));
allFrameBundles[currentScoreToken.Value].Add(bundle);
}

pendingFrames.Clear();
lastPurgeTime = Time.Current;
Expand Down
7 changes: 7 additions & 0 deletions osu.Game/Online/Spectator/SpectatorState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public class SpectatorState : IEquatable<SpectatorState>
[Key(4)]
public Dictionary<HitResult, int> MaximumStatistics { get; set; } = new Dictionary<HitResult, int>();

/// <summary>
/// The sequence number of the last frame bundle in the replay.
/// Only available in <see cref="ISpectatorServer.EndPlaySession"/> invocations.
/// </summary>
[Key(5)]
public long? LastFrameBundleSequenceNumber { get; set; }

public bool Equals(SpectatorState other)
{
if (ReferenceEquals(null, other)) return false;
Expand Down
Loading