diff --git a/Directory.Build.props b/Directory.Build.props index 53ad973e47..894ea25c8b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index e28053d0ca..e9b92be0c3 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 027bd0b7e2..e145dd7b69 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index e2c715d385..a301432a6c 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 027bd0b7e2..e145dd7b69 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 0bcbfc4baf..fec96c9165 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -20,6 +20,7 @@ namespace osu.Android [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")] diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index 6457ec92da..4c8b9b2b08 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index f1b51e51d0..6f3e6763bd 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { - ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), }; } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs index d53d019e90..1b7d254321 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { - ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), }; } diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index 674a22df98..fad39ef9d6 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index 6ae854e7f3..86f667466f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { - ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), }; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs index 064c55ed8d..1c06bb389b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { - ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), }; } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 15675e74d1..7cd06c5225 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.5867229481955389d, "diffcalc-test")] - [TestCase(1.0416315570967911d, "zero-length-sliders")] + [TestCase(6.5295339534769958d, "diffcalc-test")] + [TestCase(1.1514260533755143d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.2730989071947896d, "diffcalc-test")] - [TestCase(1.2726413186221039d, "zero-length-sliders")] + [TestCase(9.047752485219954d, "diffcalc-test")] + [TestCase(1.3985711787077566d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs new file mode 100644 index 0000000000..ef05bcd320 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneNoSpinnerStacking : TestSceneOsuPlayer + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 10 }, + Ruleset = ruleset + } + }; + + for (int i = 0; i < 512; i++) + { + if (i % 32 < 20) + beatmap.HitObjects.Add(new Spinner { Position = new Vector2(256, 192), StartTime = i * 200, EndTime = (i * 200) + 100 }); + } + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index f5f1159542..66f4ad3d3f 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 49ac6a7af3..4b90285fd4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -12,20 +12,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalized_radius = 52; + private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int min_delta_time = 25; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms to account for simultaneous s. - /// - public double StrainTime { get; private set; } - /// /// Normalized distance from the end position of the previous to the start position of this . /// public double JumpDistance { get; private set; } + /// + /// Minimum distance from the end position of the previous to the start position of this . + /// + public double MovementDistance { get; private set; } + /// /// Normalized distance between the start and end position of the previous . /// @@ -37,6 +38,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + /// + /// Milliseconds elapsed since the end time of the previous , with a minimum of 25ms. + /// + public double MovementTime { get; private set; } + + /// + /// Milliseconds elapsed since the start time of the previous to the end time of the same previous , with a minimum of 25ms. + /// + public double TravelTime { get; private set; } + + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// + public readonly double StrainTime; + private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -46,13 +62,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing this.lastLastObject = (OsuHitObject)lastLastObject; this.lastObject = (OsuHitObject)lastObject; - setDistances(); + // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. + StrainTime = Math.Max(DeltaTime, min_delta_time); - // Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects. - StrainTime = Math.Max(DeltaTime, 25); + setDistances(clockRate); } - private void setDistances() + private void setDistances(double clockRate) { // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || lastObject is Spinner) @@ -67,15 +83,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing scalingFactor *= 1 + smallCircleBonus; } + Vector2 lastCursorPosition = getEndCursorPosition(lastObject); + JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + if (lastObject is Slider lastSlider) { computeSliderCursorPosition(lastSlider); TravelDistance = lastSlider.LazyTravelDistance * scalingFactor; + TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); + MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time); + + // Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance. + float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor; + + // For hitobjects which continue in the direction of the slider, the player will normally follow through the slider, + // such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider. + // In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance. + MovementDistance = Math.Min(JumpDistance, tailJumpDistance); + } + else + { + MovementTime = StrainTime; + MovementDistance = JumpDistance; } - - Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; if (lastLastObject != null && !(lastLastObject is Spinner)) { @@ -98,7 +128,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition; - float approxFollowCircleRadius = (float)(slider.Radius * 3); + float followCircleRadius = (float)(slider.Radius * 2.4); var computeVertex = new Action(t => { double progress = (t - slider.StartTime) / slider.SpanDuration; @@ -111,11 +141,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing var diff = slider.StackedPosition + slider.Path.PositionAt(progress) - slider.LazyEndPosition.Value; float dist = diff.Length; - if (dist > approxFollowCircleRadius) + slider.LazyTravelTime = t - slider.StartTime; + + if (dist > followCircleRadius) { // The cursor would be outside the follow circle, we need to move it diff.Normalize(); // Obtain direction of diff - dist -= approxFollowCircleRadius; + dist -= followCircleRadius; slider.LazyEndPosition += diff * dist; slider.LazyTravelDistance += dist; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 64ca567a15..a054b46366 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -14,53 +14,96 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Aim : OsuStrainSkill { - private const double angle_bonus_begin = Math.PI / 3; - private const double timing_threshold = 107; - public Aim(Mod[] mods) : base(mods) { } + protected override int HistoryLength => 2; + + private const double wide_angle_multiplier = 1.5; + private const double acute_angle_multiplier = 2.0; + private double currentStrain = 1; - private double skillMultiplier => 26.25; + private double skillMultiplier => 23.25; private double strainDecayBase => 0.15; private double strainValueOf(DifficultyHitObject current) { - if (current.BaseObject is Spinner) + if (current.BaseObject is Spinner || Previous.Count <= 1 || Previous[0].BaseObject is Spinner) return 0; - var osuCurrent = (OsuDifficultyHitObject)current; + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)Previous[0]; + var osuLastLastObj = (OsuDifficultyHitObject)Previous[1]; - double aimStrain = 0; + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. + double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; - if (Previous.Count > 0) + // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. + if (osuLastObj.BaseObject is Slider) { - var osuPrevious = (OsuDifficultyHitObject)Previous[0]; + double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object + double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end. - if (osuCurrent.Angle != null && osuCurrent.Angle.Value > angle_bonus_begin) + currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. + } + + // As above, do the same for the previous hitobject. + double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime; + + if (osuLastLastObj.BaseObject is Slider) + { + double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime; + double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; + + prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); + } + + double angleBonus = 0; + double aimStrain = currVelocity; // Start strain with regular velocity. + + if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + { + if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null) { - const double scale = 90; + double currAngle = osuCurrObj.Angle.Value; + double lastAngle = osuLastObj.Angle.Value; + double lastLastAngle = osuLastLastObj.Angle.Value; - double angleBonus = Math.Sqrt( - Math.Max(osuPrevious.JumpDistance - scale, 0) - * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) - * Math.Max(osuCurrent.JumpDistance - scale, 0)); - aimStrain = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); + // Rewarding angles, take the smaller velocity as base. + angleBonus = Math.Min(currVelocity, prevVelocity); + + double wideAngleBonus = calcWideAngleBonus(currAngle); + double acuteAngleBonus = calcAcuteAngleBonus(currAngle); + + if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + acuteAngleBonus = 0; + else + { + acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. + * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + } + + wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. + acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse. + + angleBonus = acuteAngleBonus * acute_angle_multiplier + wideAngleBonus * wide_angle_multiplier; // add the angle buffs together. } } - double jumpDistanceExp = applyDiminishingExp(osuCurrent.JumpDistance); - double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance); + aimStrain += angleBonus; // Add in angle bonus. - return Math.Max( - aimStrain + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), - (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime - ); + return aimStrain; } + private double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2); + + private double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle); + private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 7c45b2bc07..8b7de9e109 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Objects set => StackHeightBindable.Value = value; } - public Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f); + public virtual Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f); public double Radius => OBJECT_RADIUS * Scale; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 9b2babb9ff..5c1c3fd253 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -79,6 +79,12 @@ namespace osu.Game.Rulesets.Osu.Objects /// internal float LazyTravelDistance; + /// + /// The time taken by the cursor upon completion of this if it was hit + /// with as few movements as possible. This is set and used by difficulty calculation. + /// + internal double LazyTravelTime; + public IList> NodeSamples { get; set; } = new List>(); [JsonIgnore] diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index f85dc0d391..0ad8e4ea68 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects { @@ -31,6 +32,8 @@ namespace osu.Game.Rulesets.Osu.Objects /// public int MaximumBonusSpins { get; protected set; } = 1; + public override Vector2 StackOffset => Vector2.Zero; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index b9b295767e..568e35c221 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index c75714032e..ecc9c92025 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -38,6 +38,15 @@ namespace osu.Game.Tests.Skins.IO assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); }); + [Test] + public Task TestSingleImportMissingSectionHeader() => runSkinTest(async osu => + { + var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk")); + + // When the import filename doesn't match, it should be appended (and update the skin.ini). + assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); + }); + [Test] public Task TestSingleImportMatchingFilename() => runSkinTest(async osu => { @@ -199,21 +208,23 @@ namespace osu.Game.Tests.Skins.IO return zipStream; } - private MemoryStream createOskWithIni(string name, string author, bool makeUnique = false, string iniFilename = @"skin.ini") + private MemoryStream createOskWithIni(string name, string author, bool makeUnique = false, string iniFilename = @"skin.ini", bool includeSectionHeader = true) { var zipStream = new MemoryStream(); using var zip = ZipArchive.Create(); - zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique)); + zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique, includeSectionHeader)); zip.SaveTo(zipStream); return zipStream; } - private MemoryStream generateSkinIni(string name, string author, bool makeUnique = true) + private MemoryStream generateSkinIni(string name, string author, bool makeUnique = true, bool includeSectionHeader = true) { var stream = new MemoryStream(); var writer = new StreamWriter(stream); - writer.WriteLine("[General]"); + if (includeSectionHeader) + writer.WriteLine("[General]"); + writer.WriteLine($"Name: {name}"); writer.WriteLine($"Author: {author}"); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 11caf9f498..38cf9d662f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -231,6 +231,9 @@ namespace osu.Game.Tests.Visual.Multiplayer } } }); + + AddAssert("Check participant count correct", () => client.APIRoom?.ParticipantCount.Value == 1); + AddAssert("Check participant list contains user", () => client.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } [Test] @@ -290,6 +293,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddUntilStep("wait for join", () => client.Room != null); + + AddAssert("Check participant count correct", () => client.APIRoom?.ParticipantCount.Value == 1); + AddAssert("Check participant list contains user", () => client.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index e50b150f94..2549681519 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -45,11 +45,11 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - public void TestAddNullUser() + public void TestAddUnresolvedUser() { AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 1); - AddStep("add non-resolvable user", () => Client.AddNullUser()); + AddStep("add non-resolvable user", () => Client.TestAddUnresolvedUser()); AddAssert("null user added", () => Client.Room.AsNonNull().Users.Count(u => u.User == null) == 1); AddUntilStep("two unique panels", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 2); diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index cd56cb51ae..57815d9273 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 2673c9ec9f..c0f94d49c7 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 9a0cdb387d..035f438b89 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -131,7 +131,7 @@ namespace osu.Game.Beatmaps var localRulesetInfo = rulesetInfo as RulesetInfo; // Difficulty can only be computed if the beatmap and ruleset are locally available. - if (localBeatmapInfo == null || localRulesetInfo == null) + if (localBeatmapInfo == null || localBeatmapInfo.ID == 0 || localRulesetInfo == null) { // If not, fall back to the existing star difficulty (e.g. from an online source). return Task.FromResult(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0)); diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 0509a9db47..0caee8f9cd 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -249,6 +249,23 @@ namespace osu.Game.Beatmaps public IBindable>> DownloadFailed => beatmapModelDownloader.DownloadFailed; + // Temporary method until this class supports IBeatmapSetInfo or otherwise. + public bool Download(IBeatmapSetInfo model, bool minimiseDownloadSize = false) + { + return beatmapModelDownloader.Download(new BeatmapSetInfo + { + OnlineBeatmapSetID = model.OnlineID, + Metadata = new BeatmapMetadata + { + Title = model.Metadata?.Title, + Artist = model.Metadata?.Artist, + TitleUnicode = model.Metadata?.TitleUnicode, + ArtistUnicode = model.Metadata?.ArtistUnicode, + Author = new User { Username = model.Metadata?.Author }, + } + }, minimiseDownloadSize); + } + public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false) { return beatmapModelDownloader.Download(model, minimiseDownloadSize); diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 64412675bb..6e573cc2a0 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -60,8 +60,9 @@ namespace osu.Game.Beatmaps.Drawables /// The ruleset to show the difficulty with. /// The mods to show the difficulty with. /// Whether to display a tooltip when hovered. - public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true) - : this(beatmapInfo, shouldShowTooltip) + /// Whether to perform difficulty lookup (including calculation if necessary). + public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) + : this(beatmapInfo, shouldShowTooltip, performBackgroundDifficultyLookup) { this.ruleset = ruleset ?? beatmapInfo.Ruleset; this.mods = mods ?? Array.Empty(); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 56525ddb14..0276abc3ff 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -27,7 +27,7 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseStreamInto(LineBufferedReader stream, T output) { - Section section = Section.None; + Section section = Section.General; string line; @@ -47,10 +47,7 @@ namespace osu.Game.Beatmaps.Formats if (line.StartsWith('[') && line.EndsWith(']')) { if (!Enum.TryParse(line[1..^1], out section)) - { Logger.Log($"Unknown section \"{line}\" in \"{output}\""); - section = Section.None; - } OnBeginNewSection(section); continue; @@ -148,7 +145,6 @@ namespace osu.Game.Beatmaps.Formats protected enum Section { - None, General, Editor, Metadata, diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index d60c9cfe65..69d72226ba 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -107,7 +107,8 @@ namespace osu.Game.Online.API WebRequest = CreateWebRequest(); WebRequest.Failed += Fail; WebRequest.AllowRetryOnTimeout = false; - WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}"); + if (!string.IsNullOrEmpty(API.AccessToken)) + WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}"); if (isFailing) return; diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs index 2e41723f34..5395fe0429 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs @@ -37,6 +37,7 @@ namespace osu.Game.Online.API.Requests.Responses public DateTimeOffset Date { get; set; } [JsonProperty(@"beatmap")] + [CanBeNull] public APIBeatmap Beatmap { get; set; } [JsonProperty("accuracy")] @@ -46,6 +47,7 @@ namespace osu.Game.Online.API.Requests.Responses public double? PP { get; set; } [JsonProperty(@"beatmapset")] + [CanBeNull] public APIBeatmapSet BeatmapSet { set @@ -96,7 +98,7 @@ namespace osu.Game.Online.API.Requests.Responses { TotalScore = TotalScore, MaxCombo = MaxCombo, - BeatmapInfo = Beatmap.ToBeatmapInfo(rulesets), + BeatmapInfo = Beatmap?.ToBeatmapInfo(rulesets), User = User, Accuracy = Accuracy, OnlineScoreID = OnlineID, diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 28505f6b0e..0586e0ae60 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -148,6 +148,10 @@ namespace osu.Game.Online.Multiplayer { Room = joinedRoom; APIRoom = room; + + Debug.Assert(LocalUser != null); + addUserToAPIRoom(LocalUser); + foreach (var user in joinedRoom.Users) updateUserPlayingState(user.UserID, user.State); @@ -372,6 +376,8 @@ namespace osu.Game.Online.Multiplayer Room.Users.Add(user); + addUserToAPIRoom(user); + UserJoined?.Invoke(user); RoomUpdated?.Invoke(); }); @@ -391,6 +397,18 @@ namespace osu.Game.Online.Multiplayer return handleUserLeft(user, UserKicked); } + private void addUserToAPIRoom(MultiplayerRoomUser user) + { + Debug.Assert(APIRoom != null); + + APIRoom.RecentParticipants.Add(user.User ?? new User + { + Id = user.UserID, + Username = "[Unresolved]" + }); + APIRoom.ParticipantCount.Value++; + } + private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) { if (Room == null) @@ -404,6 +422,10 @@ namespace osu.Game.Online.Multiplayer Room.Users.Remove(user); PlayingUserIds.Remove(user.UserID); + Debug.Assert(APIRoom != null); + APIRoom.RecentParticipants.RemoveAll(u => u.Id == user.UserID); + APIRoom.ParticipantCount.Value--; + callback?.Invoke(user); RoomUpdated?.Invoke(); }, false); diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 6cd735af23..a642e283f9 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -53,7 +53,10 @@ namespace osu.Game.Online.Rooms downloadTracker?.RemoveAndDisposeImmediately(); downloadTracker = new BeatmapDownloadTracker(item.NewValue.Beatmap.Value.BeatmapSet); - downloadTracker.State.BindValueChanged(_ => updateAvailability()); + + AddInternal(downloadTracker); + + downloadTracker.State.BindValueChanged(_ => updateAvailability(), true); downloadTracker.Progress.BindValueChanged(_ => { if (downloadTracker.State.Value != DownloadState.Downloading) @@ -63,9 +66,7 @@ namespace osu.Game.Online.Rooms // we don't want to flood the network with this, so rate limit how often we send progress updates. if (progressUpdate?.Completed != false) progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); - }); - - AddInternal(downloadTracker); + }, true); }, true); } diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index 9e432fa99e..d5da6c401c 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -5,6 +5,7 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Game.Online.API; +using osu.Game.Online.Solo; using osu.Game.Scoring; namespace osu.Game.Online.Rooms @@ -14,14 +15,14 @@ namespace osu.Game.Online.Rooms private readonly long scoreId; private readonly long roomId; private readonly long playlistItemId; - private readonly ScoreInfo scoreInfo; + private readonly SubmittableScore score; public SubmitRoomScoreRequest(long scoreId, long roomId, long playlistItemId, ScoreInfo scoreInfo) { this.scoreId = scoreId; this.roomId = roomId; this.playlistItemId = playlistItemId; - this.scoreInfo = scoreInfo; + score = new SubmittableScore(scoreInfo); } protected override WebRequest CreateWebRequest() @@ -31,7 +32,7 @@ namespace osu.Game.Online.Rooms req.ContentType = "application/json"; req.Method = HttpMethod.Put; - req.AddRaw(JsonConvert.SerializeObject(scoreInfo, new JsonSerializerSettings + req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore })); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs index d7c2837f4d..5ed49cf384 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -86,7 +86,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels break; default: - beatmaps.Download(new BeatmapSetInfo { OnlineBeatmapSetID = beatmapSet.OnlineID }, noVideoSetting.Value); + beatmaps.Download(beatmapSet, noVideoSetting.Value); break; } }; diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index 6862864c55..bd7723d3c0 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -108,7 +108,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons return; } - beatmaps.Download(new BeatmapSetInfo { OnlineBeatmapSetID = beatmapSet.OnlineID }, noVideo); + beatmaps.Download(beatmapSet, noVideo); }; localUser.BindTo(api.LocalUser); diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index ca5534dbc2..fb464e1b41 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -91,7 +91,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { new OsuSpriteText { - Text = $"{Score.Beatmap.DifficultyName}", + Text = $"{Score.Beatmap?.DifficultyName}", Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Colour = colours.Yellow }, diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index eab81186d5..5b4284dc2f 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -58,6 +58,11 @@ namespace osu.Game.Rulesets.Difficulty return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); } + /// + /// Calculates the difficulty of the beatmap and returns a set of representing the difficulty at every relevant time value in the beatmap. + /// + /// The mods that should be applied to the beatmap. + /// The set of . public List CalculateTimed(params Mod[] mods) { preProcess(mods); @@ -77,7 +82,7 @@ namespace osu.Game.Rulesets.Difficulty foreach (var skill in skills) skill.ProcessInternal(hitObject); - attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime * clockRate, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); } return attribs; diff --git a/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs index 973b2dacb2..2509971389 100644 --- a/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs @@ -11,9 +11,21 @@ namespace osu.Game.Rulesets.Difficulty /// public class TimedDifficultyAttributes : IComparable { + /// + /// The non-clock-adjusted time value at which the attributes take effect. + /// public readonly double Time; + + /// + /// The attributes. + /// public readonly DifficultyAttributes Attributes; + /// + /// Creates new . + /// + /// The non-clock-adjusted time value at which the attributes take effect. + /// The attributes. public TimedDifficultyAttributes(double time, DifficultyAttributes attributes) { Time = time; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 69ab7225ac..6f947bd398 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -105,7 +105,7 @@ namespace osu.Game.Screens.OnlinePlay private void refresh() { - difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods) { Size = new Vector2(32) }; + difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(32) }; beatmapText.Clear(); beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString(), null, text => diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 76d36ae7d9..0739026544 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -194,68 +194,61 @@ namespace osu.Game.Skinning string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; + string[] newLines = + { + @"// The following content was automatically added by osu! during import, based on filename / folder metadata.", + @"[General]", + nameLine, + authorLine, + }; + var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); - if (existingFile != null) + if (existingFile == null) { - List outputLines = new List(); + // In the case a skin doesn't have a skin.ini yet, let's create one. + writeNewSkinIni(); + return; + } - bool addedName = false; - bool addedAuthor = false; - - using (var stream = Files.Storage.GetStream(existingFile.FileInfo.StoragePath)) - using (var sr = new StreamReader(stream)) + using (Stream stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { - string line; - - while ((line = sr.ReadLine()) != null) + using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.StoragePath)) + using (var sr = new StreamReader(existingStream)) { - if (line.StartsWith(@"Name:", StringComparison.Ordinal)) - { - outputLines.Add(nameLine); - addedName = true; - } - else if (line.StartsWith(@"Author:", StringComparison.Ordinal)) - { - outputLines.Add(authorLine); - addedAuthor = true; - } - else - outputLines.Add(line); - } - } - - if (!addedName || !addedAuthor) - { - outputLines.AddRange(new[] - { - @"[General]", - nameLine, - authorLine, - }); - } - - using (Stream stream = new MemoryStream()) - { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - foreach (string line in outputLines) + string line; + while ((line = sr.ReadLine()) != null) sw.WriteLine(line); } - ReplaceFile(item, existingFile, stream); + sw.WriteLine(); + + foreach (string line in newLines) + sw.WriteLine(line); + } + + ReplaceFile(item, existingFile, stream); + + // can be removed 20220502. + if (!ensureIniWasUpdated(item)) + { + Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); + + DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))); + writeNewSkinIni(); } } - else + + void writeNewSkinIni() { using (Stream stream = new MemoryStream()) { using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { - sw.WriteLine(@"[General]"); - sw.WriteLine(nameLine); - sw.WriteLine(authorLine); - sw.WriteLine(@"Version: latest"); + foreach (string line in newLines) + sw.WriteLine(line); } AddFile(item, stream, @"skin.ini"); @@ -263,6 +256,17 @@ namespace osu.Game.Skinning } } + private bool ensureIniWasUpdated(SkinInfo item) + { + // This is a final consistency check to ensure that hash computation doesn't enter an infinite loop. + // With other changes to the surrounding code this should never be hit, but until we are 101% sure that there + // are no other cases let's avoid a hard startup crash by bailing and alerting. + + var instance = GetSkin(item); + + return instance.Configuration.SkinInfo.Name == item.Name; + } + protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { var instance = GetSkin(model); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index cd0f070d73..c6634abe82 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -62,7 +62,7 @@ namespace osu.Game.Tests.Visual.Multiplayer return roomUser; } - public void AddNullUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); + public void TestAddUnresolvedUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.UNRESOLVED_USER_ID)); private void addUser(MultiplayerRoomUser user) { diff --git a/osu.Game/Tests/Visual/TestUserLookupCache.cs b/osu.Game/Tests/Visual/TestUserLookupCache.cs index b73e81d0dd..fcb9c070ff 100644 --- a/osu.Game/Tests/Visual/TestUserLookupCache.cs +++ b/osu.Game/Tests/Visual/TestUserLookupCache.cs @@ -14,11 +14,11 @@ namespace osu.Game.Tests.Visual /// A special user ID which would return a for. /// As a simulation to what a regular would return in the case of failing to fetch the user. /// - public const int NULL_USER_ID = -1; + public const int UNRESOLVED_USER_ID = -1; protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) { - if (lookup == NULL_USER_ID) + if (lookup == UNRESOLVED_USER_ID) return Task.FromResult((User)null); return Task.FromResult(new User diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 8052ab5254..c1c3336b5c 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -23,9 +23,9 @@ - - - + + + @@ -38,8 +38,8 @@ - - + + diff --git a/osu.iOS.props b/osu.iOS.props index d152cb7066..0baf067a63 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -94,7 +94,7 @@ - +