What the osu! editor presents as a “snap position” actually involves three separate values: the resnap time, the grid rendering time, and the grid time. They share timing points and beat divisors, but do not necessarily use the same origin, operation order, rounding mode, or integer conversion.
| Time | Purpose | Does it decide an integer time? | Viewport / direction dependent? |
|---|---|---|---|
| Resnap time | Move object starts and ends to the selected divisor | Stable converts to an integer; lazer stores a double internally | Selects the closest snap to the old object time |
| Grid rendering time | Draw beat and bar lines | No; it remains a rendering coordinate | The visible range affects generation |
| Grid time | Move the editor clock to the previous or next snap | Stable constructs integer candidates; lazer retains doubles | Depends on scroll direction and timing-point boundaries |
osu!stable is not open source, so the editor paths were followed in disassembled IL.
The resnap analysis centres on method 0600347E and its caller; the analysis of the grid time calculation centres on 06003484.
The positions of float64 operations, Math.Round, and conv.i4 were included.
Method IDs and obfuscated names are specific to the analysed build.
The following links are pinned to the commit used during this analysis.
redTime is the red timing-point time, beatLength its beat length,
beatSnap the selected divisor, and objectTime the input time.snapLength = beatLength / beatSnap. trunc removes the fractional portion towards zero.
relative = objectTime - redTime
ratio = relative / (beatLength / beatSnap)
snapIndex = Math.Round(ratio)
snapTime = redTime + snapIndex * (beatLength / beatSnap)
integerTime = conv.i4(snapTime)
Math.Round(double) uses midpoint-to-even rounding.float64; its caller immediately applies conv.i4.conv.i4 truncates towards zero.
Stable finds a beat position before the left edge of the visible range, then repeatedly adds snapLength while drawing lines.
The positions remain floating-point rendering values rather than integer note timestamps.
gridTime = beat position before the viewport's left edge
while (gridTime < viewportRight):
draw a line at gridTime
gridTime += beatLength / beatSnap
There is no integer-millisecond rounding rule here. The relevant numerical effects are repeated-addition error and conversion to screen coordinates. The viewport-dependent origin also makes this unsuitable as the definition of resnap time.
Grid time calculation uses a separate method. If the first red timing point is positive, stable repeatedly subtracts its full beat length until an origin at or before zero is reached.
retTiming = activeRedTime
if (activeRed is the first red point && beatLength > 0):
while (retTiming > 0):
retTiming -= beatLength
snapLength = beatLength / beatSnap
relative = targetTime - retTiming
baseIndex =
relative < 0
? trunc(relative / snapLength) - 1
: trunc(relative / snapLength)
lower = conv.i4(retTiming + baseIndex * snapLength)
upper = conv.i4(retTiming + (baseIndex + 1) * snapLength)
seekTime = the closer integer candidate
ties select upper
The candidates are integerised before one is selected. The origin and conversion order therefore differ from stable resnap.
snapLength = timingPoint.BeatLength / beatDivisor
beats = (Math.Max(objectTime, 0) - timingPoint.Time) / snapLength
snapIndex = Math.Round(beats, MidpointRounding.AwayFromZero)
snapTime = timingPoint.Time + snapIndex * snapLength
if (snapTime < 0):
snapTime += snapLength
return snapTime // double
double without integer conversion.See ControlPointInfo.cs and EditorBeatmap.cs.
Lazer repeatedly adds the snap length from each timing point until the next timing point.
The loop variable is a double; it is cast to float for the visual X position.
for each timingPoint:
until = next timing point or track end
for (double t = timingPoint.Time;
t < until;
t += timingPoint.BeatLength / beatDivisor):
float xPosition = (float)t
draw tick
This path does not call GetClosestSnappedTime().
See TimelineTickDisplay.cs.
Lazer does not directly call the resnap helper when calculating the grid time.
It uses Floor when moving forward and Ceiling when moving backward.
Once the directional snap index is selected, however, it builds the timestamp with the same origin and multiplication used by resnap.
seekAmount = timingPoint.BeatLength / beatDivisor
temporaryTime = currentTime + direction * seekAmount
relative = temporaryTime - timingPoint.Time
snapIndex =
direction > 0
? Floor(relative / seekAmount)
: Ceiling(relative / seekAmount)
seekTime = timingPoint.Time + snapIndex * seekAmount
The result remains a double. Additional branches prevent a no-op seek within a 0.5 ms tolerance and constrain movement at timing-point boundaries.
See EditorClock.cs.
Stable uses a different origin and integer-conversion order in the two operations. A mathematically integral snap can therefore become an exact integer in resnap but an integer-minus-epsilon in the grid time calculation.
Test beatmap: USAO - Glitch in My System (polytone Edit) [Charlotte's Inner Oni]
redTime = 1038
beatLength = 297.029702970297
stable grid time retTiming:
1038
→ 740.970297029703
→ 443.940594059406
→ 146.910891089109
→ -150.11881188118804
stable resnap:
snapTime = 53538
conv.i4 = 53538
stable grid time:
rawCandidate = 53537.99999999999
conv.i4 = 53537
stable resnap = 34788
stable grid time rawCandidate = 34787.99999999999
stable grid time after conv.i4 = 34787
An object at 34787 therefore matches the grid time, but “resnap all notes” moves it to 34788.
The two methods choose an index differently—nearest for resnap, directional for the grid time calculation—but use the same timestamp construction:
timingPoint.Time + snapIndex * (beatLength / beatDivisor)
When both operations refer to the same snap index, they therefore produce the same double time.
Control flow can still differ around negative times, directional movement, and timing-point boundaries.
| Point of comparison | osu!stable | osu!lazer |
|---|---|---|
| First positive red point | Extends backwards to a non-positive retTiming |
Uses the timing-point time directly |
| Candidate construction | Builds two candidates from retTiming |
Builds a directional snap directly from the timing point |
| Integer conversion | Applies conv.i4 before selecting a candidate |
Seeks using a double |
Effect of N - ε |
Becomes N - 1 |
Remains a double close to N |
| Map time | Snap | Stable grid time | Lazer grid time | Result |
|---|---|---|---|---|
00:34:787 |
1/8 | 34787 | 34788 | Stable only |
00:53:538 |
1/4 | 53537 | 53538 | Lazer only |
Modding Helper's stable grid time section compares these stable integer candidates with lazer's normal grid time. Lazer has extra directional and boundary behaviour, but its candidate times in an ordinary timing section belong to the same snap set as lazer resnap.
The core “select a nearby index, then multiply from the red point” structure is shared, but the implementations are not identical.
| Operation | osu!stable | osu!lazer |
|---|---|---|
| Ratio | (objectTime - redTime) / snapLength |
(Max(objectTime, 0) - redTime) / snapLength |
| Midpoint rounding | ToEven | AwayFromZero |
| Final result | Immediate caller conversion with conv.i4 |
Retained as a double |
| Negative result | No equivalent positive-time correction | Adds one snap length when negative |
| Following timing point | The analysed formula uses the current red point | May choose the following timing point when it is closer |
ratio = 10.5
stable:
Math.Round(10.5) = 10 // midpoint to even
lazer:
Math.Round(10.5, AwayFromZero) = 11
This index difference only appears when the ratio is exactly halfway. It normally creates a full-snap difference rather than a 1 ms difference. Extreme snap lengths, negative times, timing-point boundaries, or final integer conversion can nevertheless make the displayed integer difference 1 ms.
00:34:787 in Charlotte's Inner Oni to 34788.
double timestamps from the same origin and multiplication, so the same snap index usually gives the same time.