From a8f058141b4e4bdf69c0d49c0c0b4f35e6d9971a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 28 Nov 2025 00:21:13 +0100 Subject: [PATCH] Fix several issues with editor timestamps for objects with fractional start times in osu!mania (#35829) * Fix mania editor timestamp generation being culture-dependent Mostly closes https://github.com/ppy/osu/issues/35809. * Add failing test for notes with fractions * Round note time when copying out timestamp & apply half-millisecond tolerance when parsing Closes the rest of https://github.com/ppy/osu/issues/35809. One issue here was that while the timestamp generation would allow fractional object timestamps to be output, the parsing (via `selection_regex`) would *reject* fractional timestamps, therefore making lazer incompatible even with itself. The other is that rounding is probably fine to do anyway for interoperability with stable. I'd hope nobody actually *needs* sub-millisecond precision but I'm ready to be proven wrong by some aspire jokester. * Specify invariant culture when writing out combo indices to editor timestamp in other rulesets Pretty sure this is just a much-of-muchness because it's integers but might as well if I'm spending time here already. --- .../Edit/CatchHitObjectComposer.cs | 4 ++- .../TestSceneOpenEditorTimestampInMania.cs | 27 ++++++++++--------- .../Edit/ManiaHitObjectComposer.cs | 9 ++++--- .../Edit/OsuHitObjectComposer.cs | 4 ++- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 370eb37d16..be9685ce9a 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -224,7 +225,8 @@ namespace osu.Game.Rulesets.Catch.Edit #region Clipboard handling public override string ConvertSelectionToString() - => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => (h.IndexInCurrentCombo + 1).ToString(CultureInfo.InvariantCulture))); // 1,2,3,4 ... private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs index 05c881d284..ad41ad9be4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs @@ -18,15 +18,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor public void TestNormalSelection() { addStepClickLink("00:05:920 (5920|3,6623|3,6857|2,7326|1)"); - AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, new List<(int, int)> - { (5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1) } - )); + AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, [(5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1)])); addReset(); addStepClickLink("00:42:716 (42716|3,43420|2,44123|0,44357|1,45295|1)"); - AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, new List<(int, int)> - { (42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1) } - )); + AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, [(42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1)])); addReset(); AddStep("add notes to row", () => @@ -41,15 +37,20 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor EditorBeatmap.AddRange(new[] { second, third, forth }); }); addStepClickLink("00:11:545 (11545|0,11545|1,11545|2,11545|3)"); - AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, new List<(int, int)> - { (11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3) } - )); + AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, [(11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3)])); addReset(); addStepClickLink("01:36:623 (96623|1,97560|1,97677|1,97795|1,98966|1)"); - AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, new List<(int, int)> - { (96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1) } - )); + AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, [(96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1)])); + } + + [Test] + public void TestRoundingToNearestMillisecondApplied() + { + AddStep("resnap note to have fractional coordinates", + () => EditorBeatmap.HitObjects.OfType().Single(ho => ho.StartTime == 85_373 && ho.Column == 1).StartTime = 85_373.125); + addStepClickLink("01:25:373 (85373|1)"); + AddAssert("selected note", () => checkSnapAndSelectColumn(85_373.125, [(85_373.125, 1)])); } [Test] @@ -75,7 +76,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor private void addReset() => addStepClickLink("00:00:000", "reset", false); - private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(int, int)>? columnPairs = null) + private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(double, int)>? columnPairs = null) { bool checkColumns = columnPairs != null ? EditorBeatmap.SelectedHitObjects.All(x => columnPairs.Any(col => isNoteAt(x, col.Item1, col.Item2))) diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index bc20456722..7da501063d 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . 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 System.Text.RegularExpressions; using osu.Framework.Allocation; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -54,7 +56,8 @@ namespace osu.Game.Rulesets.Mania.Edit }; public override string ConvertSelectionToString() - => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => FormattableString.Invariant($"{Math.Round(h.StartTime)}|{h.Column}"))); // 123|0,456|1,789|2 ... private static readonly Regex selection_regex = new Regex(@"^\d+\|\d+(,\d+\|\d+)*$", RegexOptions.Compiled); @@ -73,10 +76,10 @@ namespace osu.Game.Rulesets.Mania.Edit if (split.Length != 2) continue; - if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column)) + if (!int.TryParse(split[0], out int time) || !int.TryParse(split[1], out int column)) continue; - ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column); + ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => Precision.AlmostEquals(h.StartTime, time, 0.5) && h.Column == column); if (current == null) continue; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 0dac4cb2df..6ff762b82f 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -171,7 +172,8 @@ namespace osu.Game.Rulesets.Osu.Edit => new OsuBlueprintContainer(this); public override string ConvertSelectionToString() - => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => (h.IndexInCurrentCombo + 1).ToString(CultureInfo.InvariantCulture))); // 1,2,3,4 ... private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled);