osu!taikoの小節線は、赤線の時刻、beat length、拍子から自動生成されます。 osu!stableとosu!lazerは同じ小節周期を使いますが、 最初の開始位置、timing point境界、浮動小数点誤差の補正、整数化が異なります。 そのため、同じ譜面でも小節線が1 msずれたり、片方だけ2重になったりすることがあります。
barLength = beatLength × meterを現在時刻へ繰り返し加えます。doubleをconv.i4で切り捨てます。1e-7 ms以内なら整数へ戻し、doubleのまま保持します。osu!stableはソースコードが公開されていないため、解析対象のosu!.exeを逆アセンブルしたILからtaikoの小節線生成経路を追跡しました。中核は解析対象buildのメソッド060020D7です。timing pointのfloat64値、小節長の反復加算、conv.i4の位置、次の赤線へ切り替える条件まで確認しています。
メソッドIDと難読化名は解析対象build固有です。
次のリンクは、今回確認した同一commitへ固定しています。
1e-7の許容差| 記号 | 意味 |
|---|---|
redTime | 赤線(uninherited timing point)の時刻 |
beatLength | 赤線に保存された1拍の長さ(ms) |
meter | 拍子の分子。通常の4/4なら4 |
barLength | beatLength × meterで求める1小節の長さ |
t | 現在の小節線候補時刻。両クライアントとも内部ではdouble |
OmitFirstBarLine | 赤線の先頭小節線を省略するフラグ。.osuではeffectsのbit 3(値8) |
barLength = beatLength × meter
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になります。
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
t += barLengthとして反復加算されます。conv.i4で整数化します。conv.i4は0方向への切り捨てです。通常の正時刻ではfloorと同じです。N - εになると、conv.i4によってN - 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
OmitFirstBarLineが有効なら、開始位置を1小節分進めます。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へ追加
1e-7 ms以内なら、その整数へ補正します。tへ次の小節長を加えるため、累積誤差もそこでリセットされます。StartTimeはdoubleのままで、stableのような切り捨てはありません。Precision.AlmostBigger(endTime, t)はendTime > t - 1e-7です。t == endTimeも前区間の小節線として生成されます。121037.99999999991のような整数直前の誤差は121038へ戻します。一方、区間終端を含むため、前後両区間から同時刻に2本生成されることがあります。| 項目 | osu!stable | osu!lazer |
|---|---|---|
| 小節長 | beatLength × meter | BeatLength × TimeSignature.Numerator |
| 進め方 | 小節長の反復加算 | 小節長の反復加算 |
| 最初の開始位置 | 最初の赤線を正の剰余へ正規化 | min(0, firstHitTime)以降の整列位置 |
| 整数付近の補正 | なし | 整数から1e-7 ms以内なら補正 |
| 最終時刻 | conv.i4で0方向へ切り捨て | doubleのまま保持 |
| 次の赤線と一致する候補 | 描画前に新区間へ切り替える | 前区間の終端と新区間の先頭の両方で生成可能 |
| 最後の生成範囲 | 最後のHitObject終端 + 1 ms | 最後のHitObject終端 + 1 ms + 1小節 |
| omit first barline | 赤線先頭の描画条件で抑制 | 開始時刻を1小節分進める |
検証譜面:USAO - Glitch in My System (polytone Edit) [Charlotte's Inner Oni]
redTime = 1038
beatLength = 297.029702970297
meter = 4
barLength = 1188.118811881188...
赤線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
この例では起こりません。lazerも途中では121037.99999999991になりますが、整数との差は約8.73e-11 msで補正範囲内です。
lazer小節線:
rawBarlineTime = 121037.99999999991
roundedTime = 121038
abs(rawBarlineTime - roundedTime) <= 1e-7
t = 121038へ補正
StartTime = 121038
N - ε → N - 1と同じ仕組みによる1 ms早い小節線は発生しません。検証譜面: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となります。
143392 + 1600 = 144992
144992 >= 次の赤線144992
→ 直前区間由来として描く前に次の赤線へ切替
→ 新しい赤線由来として144992に1本生成
前区間:
endTime = 144992
t = 144992
Precision.AlmostBigger(144992, 144992) = true
→ 992の赤線区間由来として1本
新しい区間:
startTime = 144992
omit first barline = false
→ 144992の赤線区間由来として1本
最初の赤線:
-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のため除外 |
初期候補:
-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本
stableは初期候補を描いたあと、小節長を加えた次回候補で赤線の切替を判定します。次の赤線が初期候補より前にある場合、初期候補を生成した後で内部時刻を次の赤線まで戻すため、生成順が時間上では逆転する奇妙な減少が起こります。
次の赤線でOmitFirstBarLineが無効なら、その赤線由来の先頭小節線も描画されます。今回の初期候補は、描画時のconv.i4で1457.321... → 1457 msと切り捨てられるため、次の赤線由来の1457 msと完全に重なります。
conv.i4(初期候補) = 次の赤線時刻なら、整数化後に2本が同時刻へ重なります。最初の赤線だけを-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
stableの内部的な生成順は1460 → 1457 msとなり、時刻が3 ms減少します。プレイフィールド上では近接した二重線に見えますが、計算上は同じ時刻の2本ではありません。
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本生成
conv.i4で切り捨てるため、再スナップ位置より小節線が1 ms前になることがあります。doubleのまま保持するため、同じ仕組みによる1 ms早い小節線を防ぎます。