Repository: ppy/osu (2026-06-18T12:49:19Z)

Issue:
editor can submit maps with unrankable kiai on first uninherited timing points
### Type

Game behaviour

### Bug description

in lazer, you can toggle kiai on a beatmap's first uninherited timing point. assumedly this is because it doesnt cause issues in lazer although i havent bothered checking for that. 

this causes an issue when converting the map to be backwards compatible on a lazer upload, as the first uninherited timing point gets its kiai toggled on stable, breaking the map, as per rc:

<img width="1006" height="99" alt="Image" src="https://github.com/user-attachments/assets/7084af79-3ace-4c1e-8eb5-6f5b2c9ab60e" />

for compatibility this should either not be allowed on upload or there should be an inherited timing point inserted with kiai enabled on stable for this circumstance, probably.

### Screenshots or videos

_No response_

### Version

2026.610.0-tachyon

### Logs

[compressed-logs.zip](https://github.com/user-attachments/files/28853291/compressed-logs.zip)


Discussion:

Patch:
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index db09fe41dd65..41db60b17e38 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -226,9 +226,34 @@ private void handleControlPoints(TextWriter writer)
                 {
                     writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},"));
                     writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},"));
-                    outputControlPointAt(controlPointProperties, true);
-                    lastControlPointProperties = controlPointProperties;
-                    lastControlPointProperties.SliderVelocity = 1;
+
+                    // `LegacyControlPointProperties` is a struct, thus it has by-value semantics,
+                    // thus assigning `controlPointProperties` to a local creates a copy.
+                    var timingPointProperties = controlPointProperties;
+
+                    // Slider velocity cannot be encoded in a timing point,
+                    // as the legacy format writes it out as "negative beat length".
+                    // Setting it to the default value of 1 ensures that any non-default slider velocity
+                    // will not be subject to the `IsRedundant()` guard lower down and thus still be output correctly.
+                    timingPointProperties.SliderVelocity = 1;
+
+                    // Kiai flag cannot be specified on the first timing point in the beatmap:
+                    // https://github.com/peppy/osu-stable-reference/blob/0b8b19af621dbb282773c22b36cc0453942b98d8/osu!/GameModes/Edit/Forms/TimingEntry.cs#L492-L495
+                    // That fact is also codified in the ranking criteria (https://osu.ppy.sh/wiki/en/Ranking_criteria#rules.2).
+                    // This is doing a thing slightly different from that in two major aspects:
+                    // - It applies to only groups with timing points, not to any control point groups.
+                    // - It applies to all groups with timing points that specify kiai time, not only the first one.
+                    // However, the two above conditions are acceptable, because:
+                    // - The ranking criteria also specifies that an inherited timing point cannot precede an uninherited timing point.
+                    //   Thus, the case of "first timing point group of beatmap does not have timing point" is fundamentally unsound,
+                    //   and guarded against via other measures such as preventing placement of objects before the first timing point.
+                    //   Therefore, checking only groups with timing points is sufficient.
+                    // - It's fine to enforce this for all uninherited timing points because an inherited point will be emitted below if necessary for kiai anyway.
+                    //   At worst doing so will result in some extra inherited points being output.
+                    timingPointProperties.EffectFlags &= ~LegacyEffectFlags.Kiai;
+
+                    outputControlPointAt(timingPointProperties, true);
+                    lastControlPointProperties = timingPointProperties;
                 }
 
                 if (controlPointProperties.IsRedundant(lastControlPointProperties))
@@ -641,7 +666,7 @@ private struct LegacyControlPointProperties
             internal int SampleBank { get; init; }
             internal int CustomSampleBank { get; init; }
             internal int SampleVolume { get; init; }
-            internal LegacyEffectFlags EffectFlags { get; init; }
+            internal LegacyEffectFlags EffectFlags { get; set; }
 
             internal bool IsRedundant(LegacyControlPointProperties other) =>
                 SliderVelocity == other.SliderVelocity &&
diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs
index 325d0cfaf6e8..ed370ebfbd75 100644
--- a/osu.Game/Screens/Edit/Timing/EffectSection.cs
+++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs
@@ -84,9 +84,9 @@ private void updateControlPointFromSlider(ValueChangedEvent<double> scrollSpeed)
             effectPoint.ScrollSpeedBindable.Value = scrollSpeed.NewValue;
         }
 
-        protected override EffectControlPoint CreatePoint()
+        protected override EffectControlPoint CreatePoint(ControlPointGroup selectedGroup)
         {
-            var reference = Beatmap.ControlPointInfo.EffectPointAt(SelectedGroup.Value.Time);
+            var reference = Beatmap.ControlPointInfo.EffectPointAt(selectedGroup.Time);
 
             return new EffectControlPoint
             {
diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs
index ba3874dcee91..b68a514b2088 100644
--- a/osu.Game/Screens/Edit/Timing/Section.cs
+++ b/osu.Game/Screens/Edit/Timing/Section.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Screens.Edit.Timing
     internal abstract partial class Section<T> : CompositeDrawable
         where T : ControlPoint
     {
-        private OsuCheckbox checkbox = null!;
+        protected OsuCheckbox Checkbox { get; private set; } = null!;
         private Container content = null!;
 
         protected FillFlowContainer Flow { get; private set; } = null!;
@@ -30,7 +30,7 @@ internal abstract partial class Section<T> : CompositeDrawable
         protected EditorBeatmap Beatmap { get; private set; } = null!;
 
         [Resolved]
-        protected Bindable<ControlPointGroup> SelectedGroup { get; private set; } = null!;
+        protected Bindable<ControlPointGroup?> SelectedGroup { get; private set; } = null!;
 
         [Resolved]
         protected IEditorChangeHandler? ChangeHandler { get; private set; }
@@ -60,7 +60,7 @@ private void load(OverlayColourProvider colours)
                     Padding = new MarginPadding { Horizontal = 10 },
                     Children = new Drawable[]
                     {
-                        checkbox = new OsuCheckbox
+                        Checkbox = new OsuCheckbox
                         {
                             Anchor = Anchor.CentreLeft,
                             Origin = Anchor.CentreLeft,
@@ -92,24 +92,24 @@ protected override void LoadComplete()
         {
             base.LoadComplete();
 
-            checkbox.Current.BindValueChanged(selected =>
+            Checkbox.Current.BindValueChanged(selected =>
             {
                 if (selected.NewValue)
                 {
                     if (SelectedGroup.Value == null)
                     {
-                        checkbox.Current.Value = false;
+                        Checkbox.Current.Value = false;
                         return;
                     }
 
                     if (ControlPoint.Value == null)
-                        SelectedGroup.Value.Add(ControlPoint.Value = CreatePoint());
+                        SelectedGroup.Value.Add(ControlPoint.Value = CreatePoint(SelectedGroup.Value));
                 }
                 else
                 {
                     if (ControlPoint.Value != null)
                     {
-                        SelectedGroup.Value.Remove(ControlPoint.Value);
+                        SelectedGroup.Value!.Remove(ControlPoint.Value);
                         ControlPoint.Value = null;
                     }
                 }
@@ -117,17 +117,18 @@ protected override void LoadComplete()
                 content.BypassAutoSizeAxes = selected.NewValue ? Axes.None : Axes.Y;
             }, true);
 
-            SelectedGroup.BindValueChanged(points =>
-            {
-                ControlPoint.Value = points.NewValue?.ControlPoints.OfType<T>().FirstOrDefault();
-                checkbox.Current.Value = ControlPoint.Value != null;
-            }, true);
-
+            SelectedGroup.BindValueChanged(OnSelectedGroupChanged, true);
             ControlPoint.BindValueChanged(OnControlPointChanged, true);
         }
 
+        protected virtual void OnSelectedGroupChanged(ValueChangedEvent<ControlPointGroup?> group)
+        {
+            ControlPoint.Value = group.NewValue?.ControlPoints.OfType<T>().FirstOrDefault();
+            Checkbox.Current.Value = ControlPoint.Value != null;
+        }
+
         protected abstract void OnControlPointChanged(ValueChangedEvent<T?> point);
 
-        protected abstract T CreatePoint();
+        protected abstract T CreatePoint(ControlPointGroup selectedGroup);
     }
 }
diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs
index 0c06a4e69bde..fa1e3c744db2 100644
--- a/osu.Game/Screens/Edit/Timing/TimingSection.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
@@ -69,6 +70,14 @@ void saveChanges()
 
         private bool isRebinding;
 
+        protected override void OnSelectedGroupChanged(ValueChangedEvent<ControlPointGroup?> group)
+        {
+            Checkbox.Current.Disabled = false;
+            base.OnSelectedGroupChanged(group);
+            // The first control point group is expected to contain timing information at all times.
+            Checkbox.Current.Disabled = group.NewValue?.Equals(Beatmap.ControlPointInfo.Groups.FirstOrDefault()) == true;
+        }
+
         protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint?> point)
         {
             if (point.NewValue != null)
@@ -83,9 +92,9 @@ protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoi
             }
         }
 
-        protected override TimingControlPoint CreatePoint()
+        protected override TimingControlPoint CreatePoint(ControlPointGroup selectedGroup)
         {
-            var reference = Beatmap.ControlPointInfo.TimingPointAt(SelectedGroup.Value.Time);
+            var reference = Beatmap.ControlPointInfo.TimingPointAt(selectedGroup.Time);
 
             return new TimingControlPoint
             {


Test patch:
diff --git a/osu.Game.Tests/Resources/kiai-time-from-the-start-lazer.osu b/osu.Game.Tests/Resources/kiai-time-from-the-start-lazer.osu
new file mode 100644
index 000000000000..912bef994842
--- /dev/null
+++ b/osu.Game.Tests/Resources/kiai-time-from-the-start-lazer.osu
@@ -0,0 +1,53 @@
+osu file format v128
+
+[General]
+AudioFilename: audio.mp3
+AudioLeadIn: 0
+PreviewTime: -1
+Countdown: 0
+SampleSet: Normal
+StackLeniency: 0.7
+Mode: 0
+LetterboxInBreaks: 0
+UseSkinSprites: 0
+WidescreenStoryboard: 0
+
+[Editor]
+DistanceSpacing: 1
+BeatDivisor: 4
+GridSize: 32
+TimelineZoom: 1
+
+[Difficulty]
+HPDrainRate: 5
+CircleSize: 5
+OverallDifficulty: 5
+ApproachRate: 5
+SliderMultiplier: 1.4
+SliderTickRate: 1
+
+[Events]
+// Background and Video events
+0,0,"",0,0
+// Storyboard Layer 0 (Background)
+// Storyboard Layer 1 (Fail)
+// Storyboard Layer 2 (Pass)
+// Storyboard Layer 3 (Foreground)
+// Storyboard Layer 4 (Overlay)
+// Storyboard Sound Samples
+// Break Periods
+
+[TimingPoints]
+678,310.880829015544,4,1,0,100,1,1
+
+[Colours]
+Combo1: 255,192,0,255
+Combo2: 0,202,0,255
+Combo3: 18,124,255,255
+Combo4: 242,24,57,255
+
+[HitObjects]
+141,145,1610,5,0,1:0:0:0:
+237,245,1921,1,0,1:0:0:0:
+320,145,2232,1,0,1:0:0:0:
+413,254,2543,1,0,1:0:0:0:
diff --git a/osu.Game.Tests/Resources/kiai-time-from-the-start-stable.osu b/osu.Game.Tests/Resources/kiai-time-from-the-start-stable.osu
new file mode 100644
index 000000000000..10b0f63c1664
--- /dev/null
+++ b/osu.Game.Tests/Resources/kiai-time-from-the-start-stable.osu
@@ -0,0 +1,55 @@
+osu file format v14
+
+[General]
+AudioFilename: audio.mp3
+AudioLeadIn: 0
+PreviewTime: -1
+Countdown: 0
+SampleSet: Soft
+StackLeniency: 0.7
+Mode: 0
+LetterboxInBreaks: 0
+WidescreenStoryboard: 0
+
+[Editor]
+DistanceSpacing: 1
+BeatDivisor: 4
+GridSize: 32
+TimelineZoom: 1
+
+[Difficulty]
+HPDrainRate:5
+CircleSize:5
+OverallDifficulty:5
+ApproachRate:5
+SliderMultiplier:1.4
+SliderTickRate:1
+
+[Events]
+//Background and Video events
+//Break Periods
+//Storyboard Layer 0 (Background)
+//Storyboard Layer 1 (Fail)
+//Storyboard Layer 2 (Pass)
+//Storyboard Layer 3 (Foreground)
+//Storyboard Layer 4 (Overlay)
+//Storyboard Sound Samples
+
+[TimingPoints]
+678,310.880829015544,4,1,0,100,1,0
+678,-66.6666666666667,4,2,1,100,0,1
+6895,151.515151515152,3,3,0,100,1,1
+6895,-133.333333333333,3,2,3,100,0,0
+
+
+[Colours]
+Combo1 : 255,192,0
+Combo2 : 0,202,0
+Combo3 : 18,124,255
+Combo4 : 242,24,57
+
+[HitObjects]
+141,145,1610,5,0,1:0:0:0:
+237,245,1921,1,0,1:0:0:0:
+320,145,2232,1,0,1:0:0:0:
+413,254,2543,1,0,1:0:0:0: