osu!stable / osu!lazer Snap-Time Calculations

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 1
Resnap time
The destination used by “Snap all notes to current snap divisor”.
Time 2
Grid rendering time
A visual coordinate used to draw beat and bar lines. It does not decide an integer hit-object timestamp.
Time 3
Grid time
The editor clock position reached when moving to the previous or next beat with the mouse wheel or arrow keys.

1. A “snap position” involves three different times

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
Important: A grid line visually overlapping an object, resnap moving to an integer time, and the grid time displayed after using the mouse wheel or arrow keys are three separate observations.

2. Analysis methods and sources

osu!stable
Reverse engineering osu!.exe

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.

osu!lazer
Official GitHub source

The following links are pinned to the commit used during this analysis.

Notation
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.

3. The three osu!stable calculations

stable 1Resnap time

relative = objectTime - redTime
ratio = relative / (beatLength / beatSnap)
snapIndex = Math.Round(ratio)
snapTime = redTime + snapIndex * (beatLength / beatSnap)
integerTime = conv.i4(snapTime)

stable 2Grid rendering time

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.

stable 3Grid 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.

4. The three osu!lazer calculations

lazer 1Resnap time

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

See ControlPointInfo.cs and EditorBeatmap.cs.

lazer 2Grid rendering time

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 3Grid time

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.

5. Stable resnap and grid times can differ by 1 ms

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.

USAO - Glitch in My System (polytone Edit) [Charlotte's Inner Oni]

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

00:53:538 / 1/4 snap

stable resnap:
    snapTime = 53538
    conv.i4 = 53538

stable grid time:
    rawCandidate = 53537.99999999999
    conv.i4 = 53537
Stable resnap53538
Stable grid time53537
Difference-1 ms

00:34:787 / 1/8 snap

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.

6. Lazer resnap and grid times usually agree

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.

7. Stable and lazer grid times can differ by 1 ms

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.

8. Stable and lazer resnap can also diverge

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.

For ordinary positive times: when the ratio is not a midpoint, both rounding modes choose the same index. Stable and lazer resnap therefore agree in most normal cases. For example, both resnap 00:34:787 in Charlotte's Inner Oni to 34788.
Summary
The three values are separate methods even though they describe the same snap concept.
Stable's early integer conversion during the grid time calculation creates common 1 ms differences from stable resnap and lazer.
Lazer calculates resnap and grid times as double timestamps from the same origin and multiplication, so the same snap index usually gives the same time.
Stable and lazer resnap also differ in rounding and boundary handling, but those differences affect a narrower set of ordinary positive timestamps.