osu!stable / osu!lazer snap時刻の計算

osu!editorで「snap位置」と呼ばれるものには、実際には 再スナップ時刻、 グリッド描画時刻、 グリッド時刻 という別々の時刻があります。 同じ赤線とbeat snap divisorを基準にしていても、計算開始点、snap番号の選び方、浮動小数点演算の順序、整数化の位置が異なるため、常に同じ整数msになるとは限りません。

時刻 1
再スナップ時刻
「すべてのノーツを再スナップする」が、ノーツを移動させる先の時刻です。
時刻 2
グリッド描画時刻
画面にbeat線を置くための描画座標です。整数のノーツ時刻を決める処理ではありません。
時刻 3
グリッド時刻
マウスホイールや左右キーで前後のbeatへ移動したとき、editorの現在位置になる時刻です。

1. 「snap位置」には3種類の時刻がある

時刻 用途 整数時刻を決めるか 表示範囲・操作方向の影響
再スナップ時刻 ノーツ開始・終了時刻を選択中のdivisorへ移動する stableは最終的に整数化。lazerは内部ではdouble 元ノーツ時刻から最も近いsnapを選ぶ
グリッド描画時刻 画面上にbeat線・小節線を描く 決めない。描画座標として保持する 表示範囲の影響を受ける
グリッド時刻 現在位置を前または後のsnapへ移動する stableは整数候補を作る。lazerはdoubleのまま 前進・後退の方向とtiming point境界の影響を受ける
重要: グリッド線がノーツと視覚的に重なっていること、再スナップでその整数時刻へ移動すること、マウスホイールや左右キーによる移動で同じ整数時刻が表示されることは、別々の事実です。 どれか1つを観察しただけでは、残り2つの計算結果まで同じとは断定できません。

2. 解析方法と情報源

osu!stable
osu!.exeのリバースエンジニアリング

osu!stableはソースコードが公開されていないため、osu!.exeを逆アセンブルしたILからeditorの処理を追跡しました。 再スナップではメソッドID 0600347Eと呼び出し元、グリッド時刻の算出処理では06003484を中心に、float64、Math.Round、conv.i4の位置まで確認しています。

メソッドIDと難読化名は解析対象build固有です。将来のstable buildや別buildで同じ番号になる保証はありません。

osu!lazer
GitHub上の公式ソースコード

osu!lazerは公開リポジトリppy/osuのソースコードから確認しました。 このページのリンクは、調査時点の同一commitへ固定しています。

表記
以下では、赤線またはtiming pointの時刻をredTime、beat lengthをbeatLength、選択中の分割数をbeatSnap、元時刻をobjectTimeとします。
snapLength = beatLength / beatSnapです。truncは0方向への小数切り捨てを表します。

3. osu!stableにおける3種類の計算

stable 1再スナップ時刻

「タイミング」→「すべてのノーツを再スナップする」では、元ノーツ時刻に最も近いsnap番号を求め、その番号から時刻を直接計算します。

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

例としてsnapTime = 53538.999...なら53538、 snapTime = 53537.999...なら53537になります。

stable 2グリッド描画時刻

stableのタイムライングリッドは、表示範囲の左端より前にあるbeat位置を描画開始点として求め、そこからsnapLengthを繰り返し加算して線を生成します。 グリッド位置は浮動小数のまま描画へ渡され、ノーツ時刻のような整数msへは変換されません。

gridTime = 画面左端より前にあるbeat位置

while (gridTime < 画面右端):
    gridTimeに線を描画
    gridTime += beatLength / beatSnap

したがって、丸め規則は「整数msへの四捨五入」ではありません。 主な誤差要因は、繰り返し加算による微小な浮動小数点誤差と、その値を画面座標へ変換するときの精度です。 また、開始点が表示範囲に依存するため、再スナップ時刻を求める処理の基準にはできません。

stable 3グリッド時刻

グリッド時刻の算出処理は再スナップとは別メソッドです。 特に、最初の赤線が正時刻にある場合は、beat lengthを繰り返し減算して0以下まで延長したretTimingを計算開始点にします。 2本目以降の赤線では、その赤線時刻が基準になります。

retTiming = activeRedTime

if (activeRedが最初の赤線 && 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 = targetTimeに近い方
同距離ならupper

4. osu!lazerにおける3種類の計算

lazer 1再スナップ時刻

lazerの全ノーツ再スナップはEditorBeatmap.SnapAllHitObjectsToCurrentDivisor()からSnapTime()を呼び、 最終的にControlPointInfo.GetClosestSnappedTime()を使用します。

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

参照: ControlPointInfo.cs、 EditorBeatmap.cs

lazer 2グリッド描画時刻

lazerのタイムラインも、各timing pointから次のtiming pointまでsnapLengthを繰り返し加算してtickを生成します。 ループ変数tはdoubleで、描画用のX位置へ渡すときにfloatへ変換されます。

for each timingPoint:
    until = 次のtimingPoint.Time または曲の終端

    for (double t = timingPoint.Time;
         t < until;
         t += timingPoint.BeatLength / beatDivisor):
        float xPosition = (float)t
        tickを描画

これは再スナップ用のGetClosestSnappedTime()を呼ぶ処理ではありません。 詳細は TimelineTickDisplay.cs のcreateTicks()で確認できます。

lazer 3グリッド時刻

lazerのグリッド時刻の算出処理は再スナップ関数を直接呼びません。 前進ではFloor、後退ではCeilingを使い、操作方向にあるsnap番号を選択します。 ただし、選んだsnap番号から時刻を作る式は再スナップと同じです。

seekAmount = timingPoint.BeatLength / beatDivisor
temporaryTime = currentTime + direction * seekAmount
relative = temporaryTime - timingPoint.Time

if (direction > 0):
    snapIndex = Floor(relative / seekAmount)
else:
    snapIndex = Ceiling(relative / seekAmount)

seekTime = timingPoint.Time + snapIndex * seekAmount

計算結果はdoubleのままです。 さらに、丸め誤差によって現在位置とほぼ同じ(許容差0.5ms)になった場合は操作方向へもう1snap進める処理、 timing point境界を越えないための処理があります。 詳細は EditorClock.cs のseek()で確認できます。

5. stableでは再スナップ時刻とグリッド時刻が1msずれることがある

stableでは、両処理が異なる計算開始点と異なる整数化順序を使います。 このため、数学的には同じsnapを表すはずの小数が、一方では整数ちょうど、もう一方では整数の直前として表現されることがあります。

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

検証譜面: USAO - Glitch in My System (polytone Edit) [Charlotte's Inner Oni]

redTime = 1038
beatLength = 297.029702970297

stableグリッド時刻のretTiming:
1038
→ 740.970297029703
→ 443.940594059406
→ 146.910891089109
→ -150.11881188118804

00:53:538 / 1/4 snap

snapLength = 297.029702970297 / 4
           = 74.25742574257426

stable再スナップ:
    redTimeから直接計算
    snapTime = 53538
    conv.i4 = 53538

stableグリッド時刻:
    retTimingから候補を計算
    rawCandidate = 53537.99999999999
    conv.i4 = 53537
stable再スナップ53538
stableグリッド時刻53537
差-1 ms

00:34:787 / 1/8 snap

stable再スナップ = 34788
stableグリッド時刻のrawCandidate = 34787.99999999999
stableグリッド時刻のconv.i4後 = 34787

この場合、譜面上の34787はグリッド時刻には一致しますが、再スナップを実行すると34788へ移動します。 これは画面表示の誤差ではなく、整数化される前の実値と整数化位置の違いです。

6. lazerでは再スナップ時刻とグリッド時刻がほとんど一致する

lazerでは2つのメソッドの役割は異なります。 再スナップは「最も近いsnap番号」、グリッド時刻の算出処理は「操作方向のsnap番号」を選びます。 しかし、選択後の時刻生成はどちらも次の形です。

timingPoint.Time + snapIndex * (beatLength / beatDivisor)

したがって、同じsnap番号を指している限り、再スナップ時刻とグリッド時刻は同じdouble時刻になります。 timing point境界、負時刻、操作方向による次/前の選択などでは制御フローが異なりますが、 通常のtiming区間内で「そのsnap番号が何msか」を比較すると、ほとんど一致します。

7. stableとlazerではグリッド時刻が1msずれることがある

stableとlazerは、グリッド候補の時刻を作る段階から異なります。

比較点 osu!stable osu!lazer
最初の正時刻の赤線 beat lengthを繰り返し引いて0以下のretTimingへ延長 timing pointの時刻をそのまま原点にする
候補時刻 retTimingから前後2候補を作る timing pointから方向別のsnap番号を直接計算
整数化 各候補へconv.i4を適用してから選択 整数化せずdoubleのままシーク
誤差の影響 N - εがN - 1へ落ちる N - εでもdouble値として保持される

Charlotte's Inner Oniでは、次の差が確認できます。

譜面時刻 snap stableグリッド時刻 lazerグリッド時刻 判定
00:34:787 1/8 34787 34788 stableのみ一致
00:53:538 1/4 53537 53538 lazerのみ一致

Modding Helperの「osu!stable グリッド時刻判定」は、このstable固有の整数候補とlazerの通常のグリッド時刻を分離して比較しています。 lazerのグリッド時刻の算出処理には方向・timing point境界の追加処理がありますが、通常区間での候補時刻はlazer再スナップと同じsnap集合です。

8. stableとlazerでは再スナップ時刻にも差が生まれる可能性がある

再スナップの基本形は両方とも「最寄りのsnap番号を選び、赤線から掛け算で時刻を作る」です。 ただし完全に同じではありません。

処理 osu!stable osu!lazer
ratio (objectTime - redTime) / snapLength (Max(objectTime, 0) - redTime) / snapLength
中央値の丸め Math.Round — ToEven Math.Round(..., AwayFromZero)
最終結果 doubleを返した直後にconv.i4 doubleのまま保持
負のsnap時刻 lazerと同じ正時刻補正はない 負ならsnapLengthを1つ加える
直後のtiming point 解析した再スナップ式では現在の赤線を基準にする 直後のtiming pointが近ければその時刻を選べる

ToEvenとAwayFromZero

ratio = 10.5

stable:
    Math.Round(10.5) = 10
    // 偶数側

lazer:
    Math.Round(10.5, AwayFromZero) = 11
    // 0から遠い側

この差は、ratioが厳密にx.5になった場合だけsnap番号へ影響します。 通常は1snap分の差になるため、一般的なBPMでは「1ms差」より大きな差になります。 ただし、極端に短いsnap間隔、負時刻、timing point境界、最終整数化との組み合わせでは、整数表示上の差が1msになる可能性もあります。

通常の正時刻では: ratioが中央値でなければ、ToEvenとAwayFromZeroは同じsnap番号を返します。 そのためstableとlazerの再スナップ時刻はほとんど一致します。 Charlotte's Inner Oniの00:34:787も、両クライアントの再スナップ先は34788です。
まとめ
同じsnapという概念を使っていても、3種類の時刻は別メソッドです。
stableではグリッド候補の早い段階で整数化するため、再スナップやlazerと1msずれる例が現れます。
lazerでは再スナップ時刻とグリッド時刻を同じ原点・同じ掛け算からdoubleとして算出するため、同じsnap番号ならほとんど一致します。
stableとlazerの再スナップにも丸め規則などの差はありますが、通常の正時刻では影響する条件が限定されています。