osu!stable / osu!lazer 小節線描画位置の計算

osu!taikoの小節線は、赤線の時刻、beat length、拍子から自動生成されます。 osu!stableとosu!lazerは同じ小節周期を使いますが、 最初の開始位置、timing point境界、浮動小数点誤差の補正、整数化が異なります。 そのため、同じ譜面でも小節線が1 msずれたり、片方だけ2重になったりすることがあります。

共通点
小節長を反復加算
barLength = beatLength × meterを現在時刻へ繰り返し加えます。
stable固有
描画時に整数化
反復加算したdoubleをconv.i4で切り捨てます。
lazer固有
整数付近を補正
整数から1e-7 ms以内なら整数へ戻し、doubleのまま保持します。

1. 調査方法と情報源

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

osu!stableはソースコードが公開されていないため、解析対象のosu!.exeを逆アセンブルしたILからtaikoの小節線生成経路を追跡しました。中核は解析対象buildのメソッド060020D7です。timing pointのfloat64値、小節長の反復加算、conv.i4の位置、次の赤線へ切り替える条件まで確認しています。

メソッドIDと難読化名は解析対象build固有です。

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

次のリンクは、今回確認した同一commitへ固定しています。

対象:このページでいう「小節線」は、osu!taikoのプレイフィールドを流れる自動生成小節線です。editorのタイムラインに描かれるbeat線・小節線とは別の生成経路です。

2. 記号と前提

記号意味
redTime赤線(uninherited timing point)の時刻
beatLength赤線に保存された1拍の長さ(ms)
meter拍子の分子。通常の4/4なら4
barLengthbeatLength × meterで求める1小節の長さ
t現在の小節線候補時刻。両クライアントとも内部ではdouble
OmitFirstBarLine赤線の先頭小節線を省略するフラグ。.osuではeffectsのbit 3(値8)
barLength = beatLength × meter

3. osu!stableの小節線生成式

stable 1最初の開始位置

stableはuninherited timing pointだけを時刻順に取得します。最初の候補tは、最初の赤線時刻を最初の小節長で正の剰余へ正規化して求めます。

barLength = firstRed.beatLength × firstRed.meter

t = firstRed.time
    - trunc(firstRed.time / barLength) × barLength

if (t < 0):
    t += barLength

truncは0方向への切り捨てです。通常の正の小節長なら、結果は0 <= t < barLengthになります。

stable 2反復加算・赤線切替・整数化

redLines = uninherited timing points
limit = 最後のHitObjectの終端時刻 + 1
i = 0
t = 最初の開始位置

while (t <= limit):
    currentRed = redLines[i]

    if not (t <= currentRed.time
            and currentRed.OmitFirstBarLine):
        drawTime = conv.i4(t)
        小節線をdrawTimeへ追加

    barLength =
        currentRed.beatLength × currentRed.meter

    if (barLength < 0.001):
        次の赤線へ切り替える
        t = 次の赤線時刻
    else:
        t += barLength

        if (次の赤線が存在
            and t >= 次の赤線時刻):
            i += 1
            t = redLines[i].time
stableの重要点:数学上は整数になる小節線でも、反復加算結果がN - εになると、conv.i4によってN - 1へ切り捨てられます。

4. osu!lazerの小節線生成式

lazer 1生成可能な最小時刻と開始位置

generationStartTime = min(0, firstHitTime)
barLength =
    timingPoint.BeatLength
    × timingPoint.TimeSignature.Numerator

if (timingPoint.Time > generationStartTime):
    startTime = timingPoint.Time
else:
    barCount = ceil(
        (generationStartTime - timingPoint.Time)
        / barLength
    )
    startTime =
        timingPoint.Time + barCount × barLength

if (timingPoint.OmitFirstBarLine):
    startTime += barLength

lazer 2反復加算・誤差補正・区間終端

endTime =
    次の赤線がある
        ? 次の赤線時刻
        : 最後のHitObject終端 + 1 + barLength

for (double t = startTime;
     Precision.AlmostBigger(endTime, t);
     t += barLength):

    roundedTime =
        Math.Round(t, MidpointRounding.AwayFromZero)

    if (abs(t - roundedTime) <= 1e-7):
        t = roundedTime

    小節線をdoubleのtへ追加
lazerの重要点:121037.99999999991のような整数直前の誤差は121038へ戻します。一方、区間終端を含むため、前後両区間から同時刻に2本生成されることがあります。

5. osu!stableとosu!lazerの違い

項目osu!stableosu!lazer
小節長beatLength × meterBeatLength × TimeSignature.Numerator
進め方小節長の反復加算小節長の反復加算
最初の開始位置最初の赤線を正の剰余へ正規化min(0, firstHitTime)以降の整列位置
整数付近の補正なし整数から1e-7 ms以内なら補正
最終時刻conv.i4で0方向へ切り捨てdoubleのまま保持
次の赤線と一致する候補描画前に新区間へ切り替える前区間の終端と新区間の先頭の両方で生成可能
最後の生成範囲最後のHitObject終端 + 1 ms最後のHitObject終端 + 1 ms + 1小節
omit first barline赤線先頭の描画条件で抑制開始時刻を1小節分進める

6. stableで小節線が再スナップ位置より1 ms前になる例

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
meter = 4
barLength = 1188.118811881188...

02:01.038の理論位置

赤線1038から101小節後は数学上121038 msですが、stableの反復加算結果は整数のわずか手前になります。

stable小節線:
    t = 1038
    t += barLength を101回

    rawBarlineTime = 121037.99999999991
    conv.i4(rawBarlineTime) = 121037
    小節線描画時刻 = 02:01.037

stableの再スナップは別処理で、選んだsnap番号から掛け算で時刻を直接作ります。この1/1 snapでは次の結果です。

stable再スナップ:
    snapTime = redTime + 404 × beatLength
    snapTime = 121038
    conv.i4(snapTime) = 121038
    再スナップ時刻 = 02:01.038
stable 小節線121037
stable 再スナップ121038
差小節線が -1 ms

osu!lazerでも同じ現象は起こるか

この例では起こりません。lazerも途中では121037.99999999991になりますが、整数との差は約8.73e-11 msで補正範囲内です。

lazer小節線:
    rawBarlineTime = 121037.99999999991
    roundedTime = 121038
    abs(rawBarlineTime - roundedTime) <= 1e-7
    t = 121038へ補正
    StartTime = 121038
lazerは小節線時刻を整数へ切り捨てず、整数直前・直後の微小誤差を整数へ戻します。そのため、stableのN - ε → N - 1と同じ仕組みによる1 ms早い小節線は発生しません。

7. stableは1本、lazerは2本になる例

CS4W - Complementary Contrast (miyagishima) [Inner Oni]

検証譜面:CS4W - Complementary Contrast (miyagishima) [Inner Oni]

直前の赤線:
    992,400,4,1,0,70,1,8
    omit first barline = true

対象の赤線:
    144992,400,4,1,0,100,1,1
    omit first barline = false

barLength = 400 × 4 = 1600
144992 - 992 = 144000 = 90小節

直前の赤線は先頭小節線を省略するため、区間の生成位置は2592, 4192, ... 143392, 144992となります。

stable次の赤線へ先に切り替える

143392 + 1600 = 144992
144992 >= 次の赤線144992
→ 直前区間由来として描く前に次の赤線へ切替
→ 新しい赤線由来として144992に1本生成
直前区間由来0本
新区間由来1本
stable 合計1本

lazer区間終端と新区間先頭の両方を生成する

前区間:
    endTime = 144992
    t = 144992
    Precision.AlmostBigger(144992, 144992) = true
    → 992の赤線区間由来として1本

新しい区間:
    startTime = 144992
    omit first barline = false
    → 144992の赤線区間由来として1本
直前区間由来1本
新区間由来1本
lazer 合計2本
この2重小節線は浮動小数点誤差ではありません。前区間の終端を生成対象に含めるlazerの境界条件と、新しい赤線からも生成する処理が重なった結果です。生成後の重複削除はありません。

8. stableは2本、lazerは1本になる例

Halozy - Sakura Saku Utopia (Hata no Kokoro) [Sakura~ Sakura~]
最初の赤線:
    -221,419.58041958042,4,1,0,40,1,0

次の赤線:
    1457,419.58041958042,4,1,0,80,1,0

最初のHitObject:
    408 ms

barLength = 419.58041958042 × 4
          = 1678.32167832168 ms

発生メカニズム

負時刻に最初の赤線がある場合、両クライアントは、その赤線と小節周期が一致する最初の非負時刻を求めます。この時刻をここでは初期候補と呼びます。今回の初期候補1457.32167832168 msは、次の赤線1457 msを約0.322 ms超えています。

クライアント初期候補の扱い今回の結果
osu!stable境界確認前に描画し、conv.i4で整数化する1457.321... → 1457 msを生成
osu!lazer次の赤線を区間終端として、描画前にdoubleで比較する1457.321... > 1457のため除外
両者の違い:stableは初期候補を先に描画してから次の赤線を確認します。lazerは初期候補が次の赤線までの区間内にあるか確認してから描画します。

stable初期候補の後に時刻が戻る

初期候補:
    -221 + 1678.32167832168
    = 1457.32167832168

1. 初期候補を先に描画
   conv.i4(1457.32167832168) = 1457
   → -221 msの赤線由来として1本

2. 小節長を加算
   t = 3135.64335664336
   t >= 次の赤線1457
   → 次の赤線へ切り替え、tを1457へ戻す

3. 次の赤線由来として1457 msに1本
最初の赤線由来1本
次の赤線由来1本
stable 合計2本

stableは初期候補を描いたあと、小節長を加えた次回候補で赤線の切替を判定します。次の赤線が初期候補より前にある場合、初期候補を生成した後で内部時刻を次の赤線まで戻すため、生成順が時間上では逆転する奇妙な減少が起こります。

次の赤線でOmitFirstBarLineが無効なら、その赤線由来の先頭小節線も描画されます。今回の初期候補は、描画時のconv.i4で1457.321... → 1457 msと切り捨てられるため、次の赤線由来の1457 msと完全に重なります。

完全な二重小節線になる条件:初期候補の生の値が次の赤線と完全一致する必要はありません。conv.i4(初期候補) = 次の赤線時刻なら、整数化後に2本が同時刻へ重なります。

test譜面最初の赤線を-218 msへ動かした場合

最初の赤線だけを-221 msから-218 msへ動かしても、初期候補が次の赤線より後になる構造は変わりません。ただし、前区間由来の線の正確な時刻は変わります。

最初の赤線:
    -218,419.58041958042,4,1,0,40,1,0

初期候補:
    -218 + 1678.32167832168
    = 1460.32167832168

前区間由来:
    conv.i4(1460.32167832168) = 1460

次の赤線由来:
    conv.i4(1457) = 1457
前区間由来1460 ms
次の赤線由来1457 ms
stableでの間隔3 ms

stableの内部的な生成順は1460 → 1457 msとなり、時刻が3 ms減少します。プレイフィールド上では近接した二重線に見えますが、計算上は同じ時刻の2本ではありません。

lazer前区間の終端条件で超過候補を除外する

generationStartTime = min(0, 408) = 0

最初の赤線区間:
    barCount = ceil((0 - (-221)) / 1678.32167832168)
             = 1
    startTime = -221 + 1 × 1678.32167832168
              = 1457.32167832168
    endTime = 次の赤線 = 1457

    Precision.AlmostBigger(1457, 1457.32167832168)
    = false
    → 前区間由来の小節線は生成されない

次の赤線区間:
    startTime = 1457
    → 1457 msに1本生成
最初の赤線由来0本
次の赤線由来1本
lazer 合計1本
まとめ
stableは反復加算結果をconv.i4で切り捨てるため、再スナップ位置より小節線が1 ms前になることがあります。
lazerは整数付近の誤差を補正し、doubleのまま保持するため、同じ仕組みによる1 ms早い小節線を防ぎます。
一方、lazerは赤線区間の終端を生成対象に含むため、前区間と新区間の小節線が同時刻に重なり、stableでは1本・lazerでは2本になることがあります。
逆にstableは、負時刻の最初の赤線から正規化した初期候補に対して、次の赤線との境界判定を行わず1回描画します。その初期候補が次の赤線以上なら、stableでは余分な小節線が1本生成され、lazerとの差が生じます。さらに整数化後の時刻が次の赤線と一致する場合は完全な二重小節線になります。