Fix replays being misrecorded if an action is pressed and released in one update frame

Closes https://github.com/ppy/osu/issues/33465 probably.

This reverts the replay frame de-duplication logic to what it was before
https://github.com/ppy/osu/pull/33148#discussion_r2091549388.

I don't have good reproduction steps. I tried to write a test case for
this that isn't just "press and release a key in the same frame",
thinking that maybe there was some loophole in the osu! touch input
mapper that may produce this situation artificially, but I could not in
many configurations. So I have to assume that this just *can happen*
organically.
This commit is contained in:
Bartłomiej Dach
2025-06-06 09:10:51 +02:00
parent f577cea715
commit 12cc8e38da
15 changed files with 59 additions and 6 deletions

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
using osuTK;
@@ -17,5 +18,8 @@ namespace osu.Game.Rulesets.EmptyFreeform.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is EmptyFreeformReplayFrame freeformFrame && Time == freeformFrame.Time && Position == freeformFrame.Position && Actions.SequenceEqual(freeformFrame.Actions);
}
}

View File

@@ -9,5 +9,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
public class PippidonReplayFrame : ReplayFrame
{
public Vector2 Position;
public override bool IsEquivalentTo(ReplayFrame other)
=> other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Position == pippidonFrame.Position;
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.EmptyScrolling.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.EmptyScrolling.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is EmptyScrollingReplayFrame scrollingFrame && Time == scrollingFrame.Time && Actions.SequenceEqual(scrollingFrame.Actions);
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.Pippidon.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Actions.SequenceEqual(pippidonFrame.Actions);
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -64,5 +65,12 @@ namespace osu.Game.Rulesets.Catch.Replays
return new LegacyReplayFrame(Time, Position, null, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is CatchReplayFrame catchFrame
&& Time == catchFrame.Time
&& Position == catchFrame.Position
&& Dashing == catchFrame.Dashing
&& Actions.SequenceEqual(catchFrame.Actions);
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -47,5 +48,8 @@ namespace osu.Game.Rulesets.Mania.Replays
return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is ManiaReplayFrame maniaFrame && Time == maniaFrame.Time && Actions.SequenceEqual(maniaFrame.Actions);
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -47,5 +48,8 @@ namespace osu.Game.Rulesets.Osu.Replays
return new LegacyReplayFrame(Time, Position.X, Position.Y, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is OsuReplayFrame osuFrame && Time == osuFrame.Time && Position == osuFrame.Position && Actions.SequenceEqual(osuFrame.Actions);
}
}

View File

@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -42,5 +43,8 @@ namespace osu.Game.Rulesets.Taiko.Replays
return new LegacyReplayFrame(Time, null, null, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is TaikoReplayFrame taikoFrame && Time == taikoFrame.Time && Actions.SequenceEqual(taikoFrame.Actions);
}
}

View File

@@ -383,6 +383,9 @@ namespace osu.Game.Tests.NonVisual
IsImportant = isImportant;
FrameIndex = frameIndex;
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is TestReplayFrame testFrame && Time == testFrame.Time && IsImportant == testFrame.IsImportant && FrameIndex == testFrame.FrameIndex;
}
private class TestInputHandler : FramedReplayInputHandler<TestReplayFrame>

View File

@@ -317,6 +317,9 @@ namespace osu.Game.Tests.Visual.Gameplay
Position = position;
Actions.AddRange(actions);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is TestReplayFrame testFrame && Time == testFrame.Time && Position == testFrame.Position && Actions.SequenceEqual(testFrame.Actions);
}
public enum TestAction

View File

@@ -353,6 +353,9 @@ namespace osu.Game.Tests.Visual.Gameplay
return new LegacyReplayFrame(Time, Position.X, Position.Y, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is TestReplayFrame testFrame && Time == testFrame.Time && Position == testFrame.Position && Actions.SequenceEqual(testFrame.Actions);
}
public enum TestAction

View File

@@ -247,12 +247,10 @@ namespace osu.Game.Online.Spectator
var convertedFrame = convertible.ToLegacy(currentBeatmap);
// only keep the last recorded frame for a given timestamp.
// this reduces redundancy of frames in the resulting replay.
//
// this is also done at `ReplayRecorded`, but needs to be done here as well
// it is also done at `ReplayRecorder`, but needs to be done here as well
// due to the flow being handled differently.
if (pendingFrames.LastOrDefault()?.Time == convertedFrame.Time)
if (pendingFrames.LastOrDefault()?.IsEquivalentTo(convertedFrame) == true)
pendingFrames[^1] = convertedFrame;
else
pendingFrames.Add(convertedFrame);

View File

@@ -64,5 +64,12 @@ namespace osu.Game.Replays.Legacy
{
return $"{Time}\t({MouseX},{MouseY})\t{MouseLeft}\t{MouseRight}\t{MouseLeft1}\t{MouseRight1}\t{MouseLeft2}\t{MouseRight2}\t{ButtonState}";
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is LegacyReplayFrame legacyFrame
&& Time == legacyFrame.Time
&& MouseX == legacyFrame.MouseX
&& MouseY == legacyFrame.MouseY
&& ButtonState == legacyFrame.ButtonState;
}
}

View File

@@ -30,5 +30,10 @@ namespace osu.Game.Rulesets.Replays
{
Time = time;
}
/// <summary>
/// Whether this frame is equivalent to <paramref name="other"/> with respect to replay recording.
/// </summary>
public virtual bool IsEquivalentTo(ReplayFrame other) => Time == other.Time;
}
}

View File

@@ -86,9 +86,8 @@ namespace osu.Game.Rulesets.UI
if (frame != null)
{
// only keep the last recorded frame for a given timestamp.
// this reduces redundancy of frames in the resulting replay.
if (last?.Time == frame.Time)
if (last?.IsEquivalentTo(frame) == true)
target.Replay.Frames[^1] = frame;
else
target.Replay.Frames.Add(frame);