osu!editorで「snap位置」と呼ばれるものには、実際には 再スナップ時刻、 グリッド描画時刻、 グリッド時刻 という別々の時刻があります。 同じ赤線とbeat snap divisorを基準にしていても、計算開始点、snap番号の選び方、浮動小数点演算の順序、整数化の位置が異なるため、常に同じ整数msになるとは限りません。
| 時刻 | 用途 | 整数時刻を決めるか | 表示範囲・操作方向の影響 |
|---|---|---|---|
| 再スナップ時刻 | ノーツ開始・終了時刻を選択中のdivisorへ移動する | stableは最終的に整数化。lazerは内部ではdouble | 元ノーツ時刻から最も近いsnapを選ぶ |
| グリッド描画時刻 | 画面上にbeat線・小節線を描く | 決めない。描画座標として保持する | 表示範囲の影響を受ける |
| グリッド時刻 | 現在位置を前または後のsnapへ移動する | stableは整数候補を作る。lazerはdoubleのまま | 前進・後退の方向とtiming point境界の影響を受ける |
osu!stableはソースコードが公開されていないため、osu!.exeを逆アセンブルしたILからeditorの処理を追跡しました。
再スナップではメソッドID 0600347Eと呼び出し元、グリッド時刻の算出処理では06003484を中心に、float64、Math.Round、conv.i4の位置まで確認しています。
メソッドIDと難読化名は解析対象build固有です。将来のstable buildや別buildで同じ番号になる保証はありません。
osu!lazerは公開リポジトリppy/osuのソースコードから確認しました。
このページのリンクは、調査時点の同一commitへ固定しています。
redTime、beat lengthをbeatLength、選択中の分割数をbeatSnap、元時刻をobjectTimeとします。snapLength = beatLength / beatSnapです。truncは0方向への小数切り捨てを表します。
「タイミング」→「すべてのノーツを再スナップする」では、元ノーツ時刻に最も近いsnap番号を求め、その番号から時刻を直接計算します。
relative = objectTime - redTime
ratio = relative / (beatLength / beatSnap)
snapIndex = Math.Round(ratio)
snapTime = redTime + snapIndex * (beatLength / beatSnap)
integerTime = conv.i4(snapTime)
Math.Round(double)は中央の値を偶数側へ丸める(ToEven / banker's rounding)。beatLength / beatSnapは、ratio算出時と最終時刻算出時に別々に実行される。float64で返り、呼び出し元が直後にconv.i4する。conv.i4は通常範囲の時刻では小数部分を0方向へ切り捨てる。
例としてsnapTime = 53538.999...なら53538、
snapTime = 53537.999...なら53537になります。
stableのタイムライングリッドは、表示範囲の左端より前にあるbeat位置を描画開始点として求め、そこからsnapLengthを繰り返し加算して線を生成します。
グリッド位置は浮動小数のまま描画へ渡され、ノーツ時刻のような整数msへは変換されません。
gridTime = 画面左端より前にあるbeat位置
while (gridTime < 画面右端):
gridTimeに線を描画
gridTime += beatLength / beatSnap
したがって、丸め規則は「整数msへの四捨五入」ではありません。 主な誤差要因は、繰り返し加算による微小な浮動小数点誤差と、その値を画面座標へ変換するときの精度です。 また、開始点が表示範囲に依存するため、再スナップ時刻を求める処理の基準にはできません。
グリッド時刻の算出処理は再スナップとは別メソッドです。
特に、最初の赤線が正時刻にある場合は、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
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
doubleを返す。referenceTimeが指定されていない場合、直後のtiming pointの方が近ければ、そのtiming point時刻を選ぶ。参照: ControlPointInfo.cs、 EditorBeatmap.cs
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のグリッド時刻の算出処理は再スナップ関数を直接呼びません。
前進では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()で確認できます。
stableでは、両処理が異なる計算開始点と異なる整数化順序を使います。 このため、数学的には同じsnapを表すはずの小数が、一方では整数ちょうど、もう一方では整数の直前として表現されることがあります。
検証譜面: 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
snapLength = 297.029702970297 / 4
= 74.25742574257426
stable再スナップ:
redTimeから直接計算
snapTime = 53538
conv.i4 = 53538
stableグリッド時刻:
retTimingから候補を計算
rawCandidate = 53537.99999999999
conv.i4 = 53537
stable再スナップ = 34788
stableグリッド時刻のrawCandidate = 34787.99999999999
stableグリッド時刻のconv.i4後 = 34787
この場合、譜面上の34787はグリッド時刻には一致しますが、再スナップを実行すると34788へ移動します。
これは画面表示の誤差ではなく、整数化される前の実値と整数化位置の違いです。
lazerでは2つのメソッドの役割は異なります。 再スナップは「最も近いsnap番号」、グリッド時刻の算出処理は「操作方向のsnap番号」を選びます。 しかし、選択後の時刻生成はどちらも次の形です。
timingPoint.Time + snapIndex * (beatLength / beatDivisor)
beatLength / beatDivisorを使う。
したがって、同じsnap番号を指している限り、再スナップ時刻とグリッド時刻は同じdouble時刻になります。
timing point境界、負時刻、操作方向による次/前の選択などでは制御フローが異なりますが、
通常のtiming区間内で「そのsnap番号が何msか」を比較すると、ほとんど一致します。
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集合です。
再スナップの基本形は両方とも「最寄りの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が近ければその時刻を選べる |
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になる可能性もあります。
00:34:787も、両クライアントの再スナップ先は34788です。
doubleとして算出するため、同じsnap番号ならほとんど一致します。