@rian8337/osu-base 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1299,12 +1299,21 @@ class DifficultyControlPoint extends ControlPoint {
1299
1299
  * The slider speed multiplier of the control point.
1300
1300
  */
1301
1301
  speedMultiplier;
1302
+ /**
1303
+ * Whether or not slider ticks should be generated at this control point.
1304
+ *
1305
+ * This exists for backwards compatibility with maps that abuse NaN slider velocity behavior on osu!stable (e.g. /b/2628991).
1306
+ */
1307
+ generateTicks;
1308
+ // Generate ticks can be made required in 3.0.
1302
1309
  constructor(values) {
1303
1310
  super(values);
1304
1311
  this.speedMultiplier = values.speedMultiplier;
1312
+ this.generateTicks = values.generateTicks ?? true;
1305
1313
  }
1306
1314
  isRedundant(existing) {
1307
- return this.speedMultiplier === existing.speedMultiplier;
1315
+ return (this.speedMultiplier === existing.speedMultiplier &&
1316
+ this.generateTicks === existing.generateTicks);
1308
1317
  }
1309
1318
  toString() {
1310
1319
  return ("{ time: " +
@@ -1716,27 +1725,42 @@ class Beatmap {
1716
1725
  * @param stats The statistics used for calculation.
1717
1726
  */
1718
1727
  maxDroidScore(stats) {
1719
- let scoreMultiplier = 1;
1720
- if (stats.mods.every((m) => m.droidRanked)) {
1721
- let scoreSpeedMultiplier = 1;
1722
- const speedMultiplier = stats.speedMultiplier;
1723
- if (speedMultiplier > 1) {
1724
- scoreSpeedMultiplier += (speedMultiplier - 1) * 0.24;
1725
- }
1726
- else if (speedMultiplier < 1) {
1727
- scoreSpeedMultiplier = Math.pow(0.3, (1 - speedMultiplier) * 4);
1728
- }
1729
- scoreMultiplier =
1730
- stats.mods.reduce((a, v) => a * v.scoreMultiplier, 1) *
1731
- scoreSpeedMultiplier;
1728
+ let scoreMultiplier = stats.mods.reduce((a, v) => a * v.droidScoreMultiplier, 1);
1729
+ const { speedMultiplier } = stats;
1730
+ if (speedMultiplier >= 1) {
1731
+ scoreMultiplier *= 1 + (speedMultiplier - 1) * 0.24;
1732
1732
  }
1733
1733
  else {
1734
- scoreMultiplier = 0;
1734
+ scoreMultiplier *= Math.pow(0.3, (1 - speedMultiplier) * 4);
1735
1735
  }
1736
- return this.maxScore(1 +
1736
+ const difficultyMultiplier = 1 +
1737
1737
  this.difficulty.od / 10 +
1738
1738
  this.difficulty.hp / 10 +
1739
- (this.difficulty.cs - 3) / 4, scoreMultiplier);
1739
+ (this.difficulty.cs - 3) / 4;
1740
+ let combo = 0;
1741
+ let score = 0;
1742
+ for (const object of this.hitObjects.objects) {
1743
+ if (!(object instanceof Slider)) {
1744
+ score += Math.floor(300 + (300 * combo * difficultyMultiplier) / 25);
1745
+ ++combo;
1746
+ continue;
1747
+ }
1748
+ const ticksPerSpan = object.ticks;
1749
+ const totalTicks = ticksPerSpan * (object.repeats + 1);
1750
+ // Apply slider head.
1751
+ score += 30;
1752
+ ++combo;
1753
+ // Apply slider repeats.
1754
+ score += 30 * object.repeats;
1755
+ combo += object.repeats;
1756
+ // Apply slider ticks.
1757
+ score += 10 * totalTicks;
1758
+ combo += totalTicks;
1759
+ // Apply slider end.
1760
+ score += Math.floor(300 + (300 * combo * difficultyMultiplier) / 25);
1761
+ ++combo;
1762
+ }
1763
+ return Math.floor(score * scoreMultiplier);
1740
1764
  }
1741
1765
  /**
1742
1766
  * Calculates the osu!standard maximum score of the beatmap without taking spinner bonus into account.
@@ -1746,6 +1770,7 @@ class Beatmap {
1746
1770
  maxOsuScore(mods = []) {
1747
1771
  const accumulatedDiffPoints = this.difficulty.cs + this.difficulty.hp + this.difficulty.od;
1748
1772
  let difficultyMultiplier = 2;
1773
+ const scoreMultiplier = mods.reduce((a, v) => a * v.pcScoreMultiplier, 1);
1749
1774
  switch (true) {
1750
1775
  case accumulatedDiffPoints <= 5:
1751
1776
  difficultyMultiplier = 2;
@@ -1763,15 +1788,6 @@ class Beatmap {
1763
1788
  difficultyMultiplier = 6;
1764
1789
  break;
1765
1790
  }
1766
- return this.maxScore(difficultyMultiplier, mods.reduce((a, v) => a * v.scoreMultiplier, 1));
1767
- }
1768
- /**
1769
- * Calculates the maximum score with a given difficulty and score multiplier.
1770
- *
1771
- * @param difficultyMultiplier The difficulty multiplier.
1772
- * @param scoreMultiplier The score multiplier.
1773
- */
1774
- maxScore(difficultyMultiplier, scoreMultiplier) {
1775
1791
  let combo = 0;
1776
1792
  let score = 0;
1777
1793
  for (const object of this.hitObjects.objects) {
@@ -1782,11 +1798,18 @@ class Beatmap {
1782
1798
  ++combo;
1783
1799
  continue;
1784
1800
  }
1785
- const tickCount = object.nestedHitObjects.filter((v) => v instanceof SliderTick).length;
1786
- // Apply sliderhead, slider repeats, and slider ticks
1787
- score += 30 * (object.repeats + 1) + 10 * tickCount;
1788
- combo += tickCount + (object.repeats + 1);
1789
- // Apply sliderend
1801
+ const ticksPerSpan = object.ticks;
1802
+ const totalTicks = ticksPerSpan * (object.repeats + 1);
1803
+ // Apply slider head.
1804
+ score += 30;
1805
+ ++combo;
1806
+ // Apply slider repeats.
1807
+ score += 30 * object.repeats;
1808
+ combo += object.repeats;
1809
+ // Apply slider ticks.
1810
+ score += 10 * totalTicks;
1811
+ combo += totalTicks;
1812
+ // Apply slider end.
1790
1813
  score += Math.floor(300 +
1791
1814
  (300 * combo * difficultyMultiplier * scoreMultiplier) / 25);
1792
1815
  ++combo;
@@ -1995,6 +2018,7 @@ class OsuHitWindow extends HitWindow {
1995
2018
  }
1996
2019
  }
1997
2020
 
2021
+ // TODO: separate droid/PC mod implementations
1998
2022
  /**
1999
2023
  * Represents a mod.
2000
2024
  */
@@ -2010,6 +2034,8 @@ class ModDoubleTime extends Mod {
2010
2034
  name = "DoubleTime";
2011
2035
  droidRanked = true;
2012
2036
  pcRanked = true;
2037
+ droidScoreMultiplier = 1.12;
2038
+ pcScoreMultiplier = 1.12;
2013
2039
  bitwise = 1 << 6;
2014
2040
  droidString = "d";
2015
2041
  droidOnly = false;
@@ -2024,6 +2050,8 @@ class ModHalfTime extends Mod {
2024
2050
  name = "HalfTime";
2025
2051
  droidRanked = true;
2026
2052
  pcRanked = true;
2053
+ droidScoreMultiplier = 0.3;
2054
+ pcScoreMultiplier = 0.3;
2027
2055
  bitwise = 1 << 8;
2028
2056
  droidString = "t";
2029
2057
  droidOnly = false;
@@ -2038,6 +2066,8 @@ class ModNightCore extends Mod {
2038
2066
  name = "NightCore";
2039
2067
  droidRanked = true;
2040
2068
  pcRanked = true;
2069
+ droidScoreMultiplier = 1.12;
2070
+ pcScoreMultiplier = 1.12;
2041
2071
  bitwise = 1 << 9;
2042
2072
  droidString = "c";
2043
2073
  droidOnly = false;
@@ -2053,6 +2083,8 @@ class ModHardRock extends Mod {
2053
2083
  bitwise = 1 << 4;
2054
2084
  droidRanked = true;
2055
2085
  pcRanked = true;
2086
+ droidScoreMultiplier = 1.06;
2087
+ pcScoreMultiplier = 1.06;
2056
2088
  droidString = "r";
2057
2089
  droidOnly = false;
2058
2090
  }
@@ -2066,6 +2098,8 @@ class ModEasy extends Mod {
2066
2098
  name = "Easy";
2067
2099
  droidRanked = true;
2068
2100
  pcRanked = true;
2101
+ droidScoreMultiplier = 0.5;
2102
+ pcScoreMultiplier = 0.5;
2069
2103
  bitwise = 1 << 1;
2070
2104
  droidString = "e";
2071
2105
  droidOnly = false;
@@ -2080,6 +2114,8 @@ class ModPrecise extends Mod {
2080
2114
  name = "Precise";
2081
2115
  droidRanked = true;
2082
2116
  pcRanked = false;
2117
+ droidScoreMultiplier = 1.06;
2118
+ pcScoreMultiplier = 1.06;
2083
2119
  bitwise = Number.NaN;
2084
2120
  droidString = "s";
2085
2121
  droidOnly = true;
@@ -2094,6 +2130,8 @@ class ModSmallCircle extends Mod {
2094
2130
  name = "SmallCircle";
2095
2131
  droidRanked = false;
2096
2132
  pcRanked = false;
2133
+ droidScoreMultiplier = 1.06;
2134
+ pcScoreMultiplier = 1;
2097
2135
  bitwise = Number.NaN;
2098
2136
  droidString = "m";
2099
2137
  droidOnly = true;
@@ -2108,6 +2146,8 @@ class ModReallyEasy extends Mod {
2108
2146
  name = "ReallyEasy";
2109
2147
  droidRanked = false;
2110
2148
  pcRanked = false;
2149
+ droidScoreMultiplier = 0.4;
2150
+ pcScoreMultiplier = 0.4;
2111
2151
  bitwise = Number.NaN;
2112
2152
  droidString = "l";
2113
2153
  droidOnly = true;
@@ -2122,6 +2162,8 @@ class ModAuto extends Mod {
2122
2162
  name = "Autoplay";
2123
2163
  droidRanked = false;
2124
2164
  pcRanked = false;
2165
+ droidScoreMultiplier = 1;
2166
+ pcScoreMultiplier = 1;
2125
2167
  bitwise = 1 << 11;
2126
2168
  droidString = "a";
2127
2169
  droidOnly = false;
@@ -2136,6 +2178,8 @@ class ModAutopilot extends Mod {
2136
2178
  name = "Autopilot";
2137
2179
  droidRanked = false;
2138
2180
  pcRanked = false;
2181
+ droidScoreMultiplier = 0.001;
2182
+ pcScoreMultiplier = 0;
2139
2183
  bitwise = 1 << 13;
2140
2184
  droidString = "p";
2141
2185
  droidOnly = false;
@@ -2150,6 +2194,8 @@ class ModFlashlight extends Mod {
2150
2194
  name = "Flashlight";
2151
2195
  droidRanked = false;
2152
2196
  pcRanked = true;
2197
+ droidScoreMultiplier = 1.12;
2198
+ pcScoreMultiplier = 1.12;
2153
2199
  bitwise = 1 << 10;
2154
2200
  droidString = "i";
2155
2201
  droidOnly = false;
@@ -2167,6 +2213,8 @@ class ModHidden extends Mod {
2167
2213
  bitwise = 1 << 3;
2168
2214
  droidRanked = true;
2169
2215
  pcRanked = true;
2216
+ droidScoreMultiplier = 1.06;
2217
+ pcScoreMultiplier = 1.06;
2170
2218
  droidString = "h";
2171
2219
  droidOnly = false;
2172
2220
  }
@@ -2180,6 +2228,8 @@ class ModNoFail extends Mod {
2180
2228
  name = "NoFail";
2181
2229
  droidRanked = true;
2182
2230
  pcRanked = true;
2231
+ droidScoreMultiplier = 0.5;
2232
+ pcScoreMultiplier = 0.5;
2183
2233
  bitwise = 1 << 0;
2184
2234
  droidString = "n";
2185
2235
  droidOnly = false;
@@ -2194,6 +2244,8 @@ class ModPerfect extends Mod {
2194
2244
  name = "Perfect";
2195
2245
  droidRanked = false;
2196
2246
  pcRanked = true;
2247
+ droidScoreMultiplier = 1;
2248
+ pcScoreMultiplier = 1;
2197
2249
  bitwise = 1 << 14;
2198
2250
  droidString = "f";
2199
2251
  droidOnly = false;
@@ -2208,6 +2260,8 @@ class ModRelax extends Mod {
2208
2260
  name = "Relax";
2209
2261
  droidRanked = false;
2210
2262
  pcRanked = false;
2263
+ droidScoreMultiplier = 0.001;
2264
+ pcScoreMultiplier = 0;
2211
2265
  bitwise = 1 << 7;
2212
2266
  droidString = "x";
2213
2267
  droidOnly = false;
@@ -2222,6 +2276,8 @@ class ModScoreV2 extends Mod {
2222
2276
  name = "ScoreV2";
2223
2277
  droidRanked = false;
2224
2278
  pcRanked = false;
2279
+ droidScoreMultiplier = 1;
2280
+ pcScoreMultiplier = 1;
2225
2281
  bitwise = 1 << 29;
2226
2282
  droidString = "v";
2227
2283
  droidOnly = false;
@@ -2236,6 +2292,8 @@ class ModSpunOut extends Mod {
2236
2292
  name = "SpunOut";
2237
2293
  droidRanked = false;
2238
2294
  pcRanked = true;
2295
+ droidScoreMultiplier = 0.9;
2296
+ pcScoreMultiplier = 0.9;
2239
2297
  bitwise = 1 << 12;
2240
2298
  droidString = "";
2241
2299
  droidOnly = false;
@@ -2250,6 +2308,8 @@ class ModSuddenDeath extends Mod {
2250
2308
  name = "Sudden Death";
2251
2309
  droidRanked = false;
2252
2310
  pcRanked = true;
2311
+ droidScoreMultiplier = 1;
2312
+ pcScoreMultiplier = 1;
2253
2313
  bitwise = 1 << 5;
2254
2314
  droidString = "u";
2255
2315
  droidOnly = false;
@@ -2264,6 +2324,8 @@ class ModTouchDevice extends Mod {
2264
2324
  name = "TouchDevice";
2265
2325
  droidRanked = true;
2266
2326
  pcRanked = true;
2327
+ droidScoreMultiplier = 1;
2328
+ pcScoreMultiplier = 1;
2267
2329
  bitwise = 1 << 2;
2268
2330
  droidString = "";
2269
2331
  droidOnly = false;
@@ -3304,6 +3366,9 @@ class Decoder {
3304
3366
  for (let line of str.split("\n")) {
3305
3367
  this.currentLine = line;
3306
3368
  ++this.line;
3369
+ if (this.shouldSkipLine(line)) {
3370
+ continue;
3371
+ }
3307
3372
  if (this.section !== BeatmapSection.metadata) {
3308
3373
  // Comments should not be stripped from metadata lines, as the song metadata may contain "//" as valid data.
3309
3374
  const index = line.indexOf("//");
@@ -3341,6 +3406,15 @@ class Decoder {
3341
3406
  }
3342
3407
  return this;
3343
3408
  }
3409
+ /**
3410
+ * Determines whether a line should be skipped.
3411
+ *
3412
+ * @param line The line to determine.
3413
+ * @returns Whether the line should be skipped.
3414
+ */
3415
+ shouldSkipLine(line) {
3416
+ return !line || line.trimStart().startsWith("//");
3417
+ }
3344
3418
  /**
3345
3419
  * Internal decoder function for decoding a line.
3346
3420
  *
@@ -3420,29 +3494,43 @@ class SectionDecoder {
3420
3494
  * @param str The string to parse.
3421
3495
  * @param min The minimum threshold. Defaults to `-ParserConstants.MAX_PARSE_VALUE`.
3422
3496
  * @param max The maximum threshold. Defaults to `ParserConstants.MAX_PARSE_VALUE`.
3497
+ * @param allowNaN Whether to allow NaN.
3423
3498
  * @returns The parsed integer.
3424
3499
  */
3425
- tryParseInt(str, min = -ParserConstants.MAX_PARSE_VALUE, max = ParserConstants.MAX_PARSE_VALUE) {
3500
+ tryParseInt(str, min = -ParserConstants.MAX_PARSE_VALUE, max = ParserConstants.MAX_PARSE_VALUE, allowNaN = false) {
3426
3501
  const num = parseInt(str);
3427
- if (!this.isNumberValid(num, min, max)) {
3428
- throw new RangeError(`Couldn't parse ${str} into an int: value is either invalid, too low, or too high`);
3502
+ if (num < min) {
3503
+ throw new RangeError("Value is too low");
3504
+ }
3505
+ if (num > max) {
3506
+ throw new RangeError("Value is too high");
3507
+ }
3508
+ if (!allowNaN && Number.isNaN(num)) {
3509
+ throw new RangeError("Not a number");
3429
3510
  }
3430
3511
  return num;
3431
3512
  }
3432
3513
  /**
3433
3514
  * Attempts to parse a string into a float.
3434
3515
  *
3435
- * Throws an exception when the resulting value is invalid (such as NaN), too low, or too high.
3516
+ * Throws an exception when the resulting value is too low or too high.
3436
3517
  *
3437
3518
  * @param str The string to parse.
3438
3519
  * @param min The minimum threshold. Defaults to `-ParserConstants.MAX_PARSE_VALUE`.
3439
3520
  * @param max The maximum threshold. Defaults to `ParserConstants.MAX_PARSE_VALUE`.
3521
+ * @param allowNaN Whether to allow NaN.
3440
3522
  * @returns The parsed float.
3441
3523
  */
3442
- tryParseFloat(str, min = -ParserConstants.MAX_PARSE_VALUE, max = ParserConstants.MAX_PARSE_VALUE) {
3524
+ tryParseFloat(str, min = -ParserConstants.MAX_PARSE_VALUE, max = ParserConstants.MAX_PARSE_VALUE, allowNaN = false) {
3443
3525
  const num = parseFloat(str);
3444
- if (!this.isNumberValid(num, min, max)) {
3445
- throw new RangeError(`Couldn't parse ${str} into a float: value is either invalid, too low, or too high`);
3526
+ if (num < min) {
3527
+ throw new RangeError("Value is too low");
3528
+ }
3529
+ if (num > max) {
3530
+ throw new RangeError("Value is too high");
3531
+ }
3532
+ if (!allowNaN && Number.isNaN(num)) {
3533
+ throw new RangeError("Not a number");
3446
3534
  }
3447
3535
  return num;
3448
3536
  }
@@ -3502,8 +3590,8 @@ class BeatmapHitObjectsDecoder extends SectionDecoder {
3502
3590
  }
3503
3591
  const repetitions = Math.max(0, this.tryParseInt(this.setPosition(s[6]), -ParserConstants.MAX_PARSE_VALUE, ParserConstants.MAX_REPETITIONS_VALUE));
3504
3592
  const distance = Math.max(0, this.tryParseFloat(this.setPosition(s[7])));
3505
- const speedMultiplierTimingPoint = this.target.controlPoints.difficulty.controlPointAt(time);
3506
- const msPerBeatTimingPoint = this.target.controlPoints.timing.controlPointAt(time);
3593
+ const difficultyControlPoint = this.target.controlPoints.difficulty.controlPointAt(time);
3594
+ const timingControlPoint = this.target.controlPoints.timing.controlPointAt(time);
3507
3595
  const points = [new Vector2(0, 0)];
3508
3596
  const pointSplit = this.setPosition(s[5]).split("|");
3509
3597
  let pathType = pointSplit.shift();
@@ -3573,6 +3661,23 @@ class BeatmapHitObjectsDecoder extends SectionDecoder {
3573
3661
  comboOffset += this.extraComboOffset;
3574
3662
  this.forceNewCombo = false;
3575
3663
  this.extraComboOffset = 0;
3664
+ let tickDistanceMultiplier = Number.POSITIVE_INFINITY;
3665
+ if (difficultyControlPoint.generateTicks) {
3666
+ if (this.isNumberValid(timingControlPoint.msPerBeat, ParserConstants.MIN_MSPERBEAT_VALUE, ParserConstants.MAX_MSPERBEAT_VALUE)) {
3667
+ // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
3668
+ // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
3669
+ //
3670
+ // This additional check is used in case BPM goes very low or very high.
3671
+ // When lazer is final, this should be revisited.
3672
+ tickDistanceMultiplier =
3673
+ this.target.formatVersion < 8
3674
+ ? 1 / difficultyControlPoint.speedMultiplier
3675
+ : 1;
3676
+ }
3677
+ else {
3678
+ tickDistanceMultiplier = 0;
3679
+ }
3680
+ }
3576
3681
  object = new Slider({
3577
3682
  position: position,
3578
3683
  startTime: time,
@@ -3582,20 +3687,11 @@ class BeatmapHitObjectsDecoder extends SectionDecoder {
3582
3687
  nodeSamples: nodeSamples,
3583
3688
  repetitions: repetitions,
3584
3689
  path: path,
3585
- speedMultiplier: MathUtils.clamp(speedMultiplierTimingPoint.speedMultiplier, ParserConstants.MIN_SPEEDMULTIPLIER_VALUE, ParserConstants.MAX_SPEEDMULTIPLIER_VALUE),
3586
- msPerBeat: msPerBeatTimingPoint.msPerBeat,
3690
+ speedMultiplier: MathUtils.clamp(difficultyControlPoint.speedMultiplier, ParserConstants.MIN_SPEEDMULTIPLIER_VALUE, ParserConstants.MAX_SPEEDMULTIPLIER_VALUE),
3691
+ msPerBeat: timingControlPoint.msPerBeat,
3587
3692
  mapSliderVelocity: this.target.difficulty.sliderMultiplier,
3588
3693
  mapTickRate: this.target.difficulty.sliderTickRate,
3589
- // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
3590
- // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
3591
- //
3592
- // This additional check is used in case BPM goes very low or very high.
3593
- // When lazer is final, this should be revisited.
3594
- tickDistanceMultiplier: this.isNumberValid(msPerBeatTimingPoint.msPerBeat, ParserConstants.MIN_MSPERBEAT_VALUE, ParserConstants.MAX_MSPERBEAT_VALUE)
3595
- ? this.target.formatVersion < 8
3596
- ? 1 / speedMultiplierTimingPoint.speedMultiplier
3597
- : 1
3598
- : 0,
3694
+ tickDistanceMultiplier: tickDistanceMultiplier,
3599
3695
  });
3600
3696
  }
3601
3697
  else if (type & exports.objectTypes.spinner) {
@@ -4143,7 +4239,9 @@ class BeatmapControlPointsDecoder extends SectionDecoder {
4143
4239
  throw new Error("Ignoring malformed timing point");
4144
4240
  }
4145
4241
  const time = this.target.getOffsetTime(this.tryParseFloat(this.setPosition(s[0])));
4146
- const msPerBeat = this.tryParseFloat(this.setPosition(s[1]));
4242
+ // msPerBeat is allowed to be NaN to handle an edge case in which some
4243
+ // beatmaps use NaN slider velocity to disable slider tick generation.
4244
+ const msPerBeat = this.tryParseFloat(this.setPosition(s[1]), undefined, undefined, true);
4147
4245
  let timeSignature = 4;
4148
4246
  if (s.length >= 3) {
4149
4247
  timeSignature = this.tryParseInt(this.setPosition(s[2]));
@@ -4170,7 +4268,14 @@ class BeatmapControlPointsDecoder extends SectionDecoder {
4170
4268
  kiaiMode = !!(effectBitFlags & EffectFlags.kiai);
4171
4269
  omitFirstBarSignature = !!(effectBitFlags & EffectFlags.omitFirstBarLine);
4172
4270
  }
4173
- if (msPerBeat >= 0) {
4271
+ let timingChange = true;
4272
+ if (s.length >= 7) {
4273
+ timingChange = s[6] === "1";
4274
+ }
4275
+ if (timingChange) {
4276
+ if (Number.isNaN(msPerBeat)) {
4277
+ throw new Error("Beat length cannot be NaN in a timing control point");
4278
+ }
4174
4279
  this.target.controlPoints.timing.add(new TimingControlPoint({
4175
4280
  time: time,
4176
4281
  msPerBeat: msPerBeat,
@@ -4179,7 +4284,9 @@ class BeatmapControlPointsDecoder extends SectionDecoder {
4179
4284
  }
4180
4285
  this.target.controlPoints.difficulty.add(new DifficultyControlPoint({
4181
4286
  time: time,
4287
+ // If msPerBeat is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false.
4182
4288
  speedMultiplier: msPerBeat < 0 ? 100 / -msPerBeat : 1,
4289
+ generateTicks: !Number.isNaN(msPerBeat),
4183
4290
  }));
4184
4291
  this.target.controlPoints.effect.add(new EffectControlPoint({
4185
4292
  time: time,