@rian8337/osu-droid-replay-analyzer 4.0.0-beta.2 → 4.0.0-beta.20

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
@@ -51,18 +51,6 @@ exports.MovementType = void 0;
51
51
  * Represents a cursor's occurrence.
52
52
  */
53
53
  class CursorOccurrence {
54
- /**
55
- * The time of this occurrence.
56
- */
57
- time;
58
- /**
59
- * The position of the occurrence.
60
- */
61
- position;
62
- /**
63
- * The movement ID of the occurrence.
64
- */
65
- id;
66
54
  constructor(time, x, y, id) {
67
55
  this.time = time;
68
56
  this.position = new osuBase.Vector2(x, y);
@@ -125,7 +113,8 @@ class CursorOccurrenceGroup {
125
113
  * The time at which this cursor occurrence group ends.
126
114
  */
127
115
  get endTime() {
128
- return this._up?.time ?? this._moves.at(-1)?.time ?? this._down.time;
116
+ var _a, _b, _c, _d;
117
+ return (_d = (_b = (_a = this._up) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : (_c = this._moves.at(-1)) === null || _c === void 0 ? void 0 : _c.time) !== null && _d !== void 0 ? _d : this._down.time;
129
118
  }
130
119
  /**
131
120
  * The duration this cursor occurrence group is active for.
@@ -145,20 +134,6 @@ class CursorOccurrenceGroup {
145
134
  }
146
135
  return cursors;
147
136
  }
148
- /**
149
- * The cursor occurrence of movement type `movementType.DOWN`.
150
- */
151
- _down;
152
- /**
153
- * The cursor occurrences of movement type `movementType.MOVE`.
154
- */
155
- _moves;
156
- /**
157
- * The cursor occurrence of movement type `movementType.UP`.
158
- *
159
- * May not exist, such as when the player holds their cursor until the end of a beatmap.
160
- */
161
- _up;
162
137
  constructor(down, moves, up) {
163
138
  this._down = down;
164
139
  this._moves = moves;
@@ -182,13 +157,14 @@ class CursorOccurrenceGroup {
182
157
  * @returns The cursor occurrence at the given time, `null` if not found.
183
158
  */
184
159
  cursorAt(time) {
160
+ var _a;
185
161
  if (!this.isActiveAt(time)) {
186
162
  return null;
187
163
  }
188
164
  if (this._down.time === time) {
189
165
  return this._down;
190
166
  }
191
- if (this._up?.time === time) {
167
+ if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
192
168
  return this._up;
193
169
  }
194
170
  let l = 0;
@@ -218,17 +194,14 @@ class CursorOccurrenceGroup {
218
194
  * This is used when analyzing replays using replay analyzer.
219
195
  */
220
196
  class CursorData {
221
- /**
222
- * The occurrence groups of this cursor instance.
223
- */
224
- occurrenceGroups = [];
225
197
  /**
226
198
  * The time at which the first occurrence of this cursor instance occurs.
227
199
  *
228
200
  * Will return `null` if there are no occurrences.
229
201
  */
230
202
  get earliestOccurrenceTime() {
231
- return this.occurrenceGroups.at(0)?.startTime ?? null;
203
+ var _a, _b;
204
+ return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
232
205
  }
233
206
  /**
234
207
  * The time at which the latest occurrence of this cursor instance occurs.
@@ -236,7 +209,8 @@ class CursorData {
236
209
  * Will return `null` if there are no occurrences.
237
210
  */
238
211
  get latestOccurrenceTime() {
239
- return this.occurrenceGroups.at(-1)?.endTime ?? null;
212
+ var _a, _b;
213
+ return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
240
214
  }
241
215
  /**
242
216
  * The amount of cursor occurrences of this cursor instance.
@@ -263,6 +237,10 @@ class CursorData {
263
237
  return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
264
238
  }
265
239
  constructor(values) {
240
+ /**
241
+ * The occurrence groups of this cursor instance.
242
+ */
243
+ this.occurrenceGroups = [];
266
244
  let downOccurrence = null;
267
245
  let moveOccurrences = [];
268
246
  for (let i = 0; i < values.size; ++i) {
@@ -312,6 +290,502 @@ exports.HitResult = void 0;
312
290
  HitResult[HitResult["great"] = 4] = "great";
313
291
  })(exports.HitResult || (exports.HitResult = {}));
314
292
 
293
+ /**
294
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
295
+ */
296
+ class RebalanceThreeFingerChecker {
297
+ /**
298
+ * @param beatmap The beatmap to analyze.
299
+ * @param data The data of the replay.
300
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
301
+ */
302
+ constructor(beatmap, data, difficultyAttributes) {
303
+ /**
304
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
305
+ *
306
+ * Increasing this number will increase detection accuracy, however
307
+ * it also increases the chance of falsely flagged plays.
308
+ */
309
+ this.threeFingerRatioThreshold = 0.01;
310
+ /**
311
+ * Extended sections of the beatmap for drag detection.
312
+ */
313
+ this.beatmapSections = [];
314
+ /**
315
+ * A reprocessed break points to match right on object time.
316
+ *
317
+ * This is used to increase detection accuracy since break points do not start right at the
318
+ * start of the hitobject before it and do not end right at the first hitobject after it.
319
+ */
320
+ this.breakPointAccurateTimes = [];
321
+ /**
322
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
323
+ *
324
+ * Each index represents the cursor index.
325
+ */
326
+ this.downCursorInstances = [];
327
+ /**
328
+ * Nerf factors from all sections that were three-fingered.
329
+ */
330
+ this.nerfFactors = [];
331
+ this.beatmap = beatmap;
332
+ this.data = data;
333
+ this.difficultyAttributes = difficultyAttributes;
334
+ const od = osuBase.calculateDroidDifficultyStatistics({
335
+ overallDifficulty: beatmap.difficulty.od,
336
+ mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
337
+ convertOverallDifficulty: false,
338
+ }).overallDifficulty;
339
+ this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
340
+ this.hitWindow = new osuBase.DroidHitWindow(od);
341
+ this.hitObjects = beatmap.hitObjects.objects;
342
+ }
343
+ /**
344
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
345
+ *
346
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
347
+ */
348
+ static isEligibleToDetect(difficultyAttributes) {
349
+ return difficultyAttributes.possibleThreeFingeredSections.length > 0;
350
+ }
351
+ /**
352
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
353
+ *
354
+ * The beatmap will be separated into sections and each section will be determined
355
+ * whether or not it is dragged.
356
+ *
357
+ * After that, each section will be assigned a nerf factor based on whether or not
358
+ * the section is 3-fingered. These nerf factors will be summed up into a final
359
+ * nerf factor, taking beatmap difficulty into account.
360
+ */
361
+ check() {
362
+ if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
363
+ return { is3Finger: false, penalty: 1 };
364
+ }
365
+ this.getAccurateBreakPoints();
366
+ this.filterCursorInstances();
367
+ if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
368
+ return { is3Finger: false, penalty: 1 };
369
+ }
370
+ this.getBeatmapSections();
371
+ this.calculateNerfFactors();
372
+ const finalPenalty = this.calculateFinalPenalty();
373
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
374
+ }
375
+ /**
376
+ * Generates a new set of "accurate break points".
377
+ *
378
+ * This is done to increase detection accuracy since break points do not start right at the
379
+ * end of the hitobject before it and do not end right at the first hitobject after it.
380
+ */
381
+ getAccurateBreakPoints() {
382
+ const objectData = this.data.hitObjectData;
383
+ for (const breakPoint of this.beatmap.events.breaks) {
384
+ const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
385
+ const objectBefore = this.hitObjects[beforeIndex];
386
+ const objectBeforeData = objectData[beforeIndex];
387
+ let timeBefore = objectBefore.endTime;
388
+ if (objectBefore instanceof osuBase.Circle) {
389
+ if (objectBeforeData.result !== exports.HitResult.miss) {
390
+ timeBefore += objectBeforeData.accuracy;
391
+ }
392
+ else {
393
+ timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
394
+ }
395
+ }
396
+ const afterIndex = beforeIndex + 1;
397
+ const objectAfter = this.hitObjects[afterIndex];
398
+ const objectAfterData = objectData[afterIndex];
399
+ let timeAfter = this.hitObjects[afterIndex].startTime;
400
+ if (objectAfter instanceof osuBase.Circle &&
401
+ objectAfterData.result !== exports.HitResult.miss) {
402
+ timeAfter += objectAfterData.accuracy;
403
+ }
404
+ this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
405
+ startTime: timeBefore,
406
+ endTime: timeAfter,
407
+ }));
408
+ }
409
+ }
410
+ /**
411
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
412
+ *
413
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
414
+ */
415
+ filterCursorInstances() {
416
+ const objectData = this.data.hitObjectData;
417
+ const firstObjectResult = objectData[0].result;
418
+ const lastObjectResult = objectData.at(-1).result;
419
+ const firstObject = this.hitObjects[0];
420
+ const lastObject = this.hitObjects.at(-1);
421
+ // For sliders, automatically set hit window length to be as lenient as possible.
422
+ let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
423
+ if (firstObject instanceof osuBase.Circle) {
424
+ switch (firstObjectResult) {
425
+ case exports.HitResult.great:
426
+ firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
427
+ break;
428
+ case exports.HitResult.good:
429
+ firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
430
+ break;
431
+ default:
432
+ firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
433
+ }
434
+ }
435
+ // For sliders, automatically set hit window length to be as lenient as possible.
436
+ let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
437
+ if (lastObject instanceof osuBase.Circle) {
438
+ switch (lastObjectResult) {
439
+ case exports.HitResult.great:
440
+ lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
441
+ break;
442
+ case exports.HitResult.good:
443
+ lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
444
+ break;
445
+ default:
446
+ lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
447
+ }
448
+ }
449
+ else if (lastObject instanceof osuBase.Slider) {
450
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
451
+ }
452
+ // These hit time uses hit window length as threshold.
453
+ // This is because cursors aren't recorded exactly at hit time,
454
+ // probably due to the game's behavior.
455
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
456
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
457
+ for (const cursorInstance of this.data.cursorMovement) {
458
+ const validOccurrences = [];
459
+ for (const group of cursorInstance.occurrenceGroups) {
460
+ if (group.startTime < firstObjectHitTime) {
461
+ continue;
462
+ }
463
+ if (group.startTime > lastObjectHitTime) {
464
+ break;
465
+ }
466
+ if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
467
+ group.endTime <= v.endTime)) {
468
+ continue;
469
+ }
470
+ validOccurrences.push(group.down);
471
+ }
472
+ this.downCursorInstances.push(validOccurrences);
473
+ }
474
+ }
475
+ /**
476
+ * Divides the beatmap into sections, which will be used to
477
+ * detect dragged sections and improve detection speed.
478
+ */
479
+ getBeatmapSections() {
480
+ const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
481
+ for (const section of this.difficultyAttributes
482
+ .possibleThreeFingeredSections) {
483
+ const dragFingerIndex = this.findDragIndex(section);
484
+ const objects = [];
485
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
486
+ objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
487
+ }
488
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
489
+ }
490
+ }
491
+ /**
492
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
493
+ *
494
+ * @param object The object to obtain the index for.
495
+ * @param objectData The hit data of the object.
496
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
497
+ * @param excludedIndices The cursor indices that should not be checked.
498
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
499
+ */
500
+ getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
501
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
502
+ return {
503
+ pressingCursorInstanceIndex: -1,
504
+ pressingCursorIndex: -1,
505
+ };
506
+ }
507
+ const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
508
+ // Check for sliderbreaks and treat them as misses.
509
+ if (object instanceof osuBase.Slider &&
510
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
511
+ return {
512
+ pressingCursorInstanceIndex: -1,
513
+ pressingCursorIndex: -1,
514
+ };
515
+ }
516
+ // We are not directly using hit time to determine which cursor pressed the object
517
+ // to account for time difference between hit registration and object judgement.
518
+ let minHitTime = object.startTime;
519
+ let maxHitTime = object.startTime;
520
+ if (object instanceof osuBase.Circle) {
521
+ let hitWindowGap = hitWindow50;
522
+ switch (objectData.result) {
523
+ case exports.HitResult.great:
524
+ hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
525
+ break;
526
+ case exports.HitResult.good:
527
+ hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
528
+ break;
529
+ }
530
+ minHitTime -= hitWindowGap;
531
+ maxHitTime += hitWindowGap;
532
+ }
533
+ else if (object instanceof osuBase.Slider) {
534
+ minHitTime -= hitWindow50;
535
+ maxHitTime += Math.min(hitWindow50, object.spanDuration);
536
+ }
537
+ const hitTime = object.startTime + objectData.accuracy;
538
+ let nearestCursorInstanceIndex = null;
539
+ let nearestCursorIndex = null;
540
+ let nearestTime = Number.POSITIVE_INFINITY;
541
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
542
+ if (excludedCursorIndices.includes(i)) {
543
+ continue;
544
+ }
545
+ const cursors = this.downCursorInstances[i];
546
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
547
+ const cursor = cursors[j];
548
+ if (cursor.time < minHitTime) {
549
+ continue;
550
+ }
551
+ if (cursor.time > maxHitTime) {
552
+ break;
553
+ }
554
+ const deltaTime = Math.abs(hitTime - cursor.time);
555
+ if (deltaTime > nearestTime) {
556
+ break;
557
+ }
558
+ nearestCursorInstanceIndex = i;
559
+ nearestCursorIndex = j;
560
+ nearestTime = deltaTime;
561
+ }
562
+ }
563
+ return {
564
+ pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
565
+ pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
566
+ };
567
+ }
568
+ /**
569
+ * Checks if a section is dragged and returns the index of the drag finger.
570
+ *
571
+ * If the section is not dragged, -1 will be returned.
572
+ *
573
+ * @param section The section to check.
574
+ */
575
+ findDragIndex(section) {
576
+ var _a;
577
+ const objectData = this.data.hitObjectData;
578
+ const firstObject = this.hitObjects[section.firstObjectIndex];
579
+ const lastObject = this.hitObjects[section.lastObjectIndex];
580
+ let firstObjectMinHitTime = firstObject.startTime;
581
+ if (firstObject instanceof osuBase.Circle) {
582
+ switch (objectData[section.firstObjectIndex].result) {
583
+ case exports.HitResult.great:
584
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
585
+ break;
586
+ case exports.HitResult.good:
587
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
588
+ break;
589
+ default:
590
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
591
+ }
592
+ }
593
+ else if (firstObject instanceof osuBase.Slider) {
594
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
595
+ }
596
+ let lastObjectMaxHitTime = lastObject.startTime;
597
+ if (lastObject instanceof osuBase.Circle) {
598
+ switch (objectData[section.lastObjectIndex].result) {
599
+ case exports.HitResult.great:
600
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
601
+ break;
602
+ case exports.HitResult.good:
603
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
604
+ break;
605
+ default:
606
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
607
+ }
608
+ }
609
+ else if (lastObject instanceof osuBase.Slider) {
610
+ lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
611
+ }
612
+ // Since there may be more than 1 cursor instance index,
613
+ // we check which cursor instance follows hitobjects all over.
614
+ const cursorIndices = [];
615
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
616
+ const c = this.data.cursorMovement[i];
617
+ if (c.occurrenceGroups.length === 0) {
618
+ continue;
619
+ }
620
+ // Do not include cursors that don't have an occurence in this section
621
+ // this speeds up checking process.
622
+ if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
623
+ v.endTime <= lastObjectMaxHitTime).length === 0) {
624
+ continue;
625
+ }
626
+ // If this cursor instance doesn't move, it's not the cursor instance we want.
627
+ if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
628
+ 0) {
629
+ continue;
630
+ }
631
+ cursorIndices.push(i);
632
+ }
633
+ const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
634
+ const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
635
+ const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
636
+ for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
637
+ const object = sectionObjects[i];
638
+ const objectData = sectionReplayObjectData[i];
639
+ if (object instanceof osuBase.Spinner ||
640
+ objectData.result === exports.HitResult.miss) {
641
+ continue;
642
+ }
643
+ // Exclude sliderbreaks.
644
+ if (object instanceof osuBase.Slider &&
645
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
646
+ continue;
647
+ }
648
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
649
+ const hitTime = object.startTime + objectData.accuracy;
650
+ // Observe the cursor position at the object's hit time.
651
+ for (let j = 0; j < cursorIndices.length; ++j) {
652
+ if (cursorIndices[j] === -1) {
653
+ continue;
654
+ }
655
+ const cursorData = this.data.cursorMovement[cursorIndices[j]];
656
+ const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
657
+ if (!cursorGroup) {
658
+ continue;
659
+ }
660
+ const cursors = cursorGroup.allOccurrences;
661
+ let isInObject = false;
662
+ for (let k = 1; k < cursors.length; ++k) {
663
+ const cursor = cursors[k];
664
+ const prevCursor = cursors[k - 1];
665
+ // Only consider cursor at interval prev.time <= hitTime <= current.time.
666
+ if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
667
+ switch (cursor.id) {
668
+ case exports.MovementType.up:
669
+ isInObject =
670
+ prevCursor.position.getDistance(objectPosition) <= object.radius;
671
+ break;
672
+ case exports.MovementType.move: {
673
+ // Interpolate movement.
674
+ const t = (hitTime - prevCursor.time) /
675
+ (cursor.time - prevCursor.time);
676
+ const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
677
+ isInObject =
678
+ objectPosition.getDistance(cursorPosition) <= object.radius;
679
+ }
680
+ }
681
+ break;
682
+ }
683
+ }
684
+ if (!isInObject) {
685
+ cursorIndices[j] = -1;
686
+ }
687
+ }
688
+ }
689
+ return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
690
+ }
691
+ /**
692
+ * Creates nerf factors by scanning through objects.
693
+ */
694
+ calculateNerfFactors() {
695
+ for (const beatmapSection of this.beatmapSections) {
696
+ const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
697
+ for (const object of beatmapSection.objects) {
698
+ if (object.pressingCursorIndex === -1 ||
699
+ object.pressingCursorInstanceIndex ===
700
+ beatmapSection.dragFingerIndex) {
701
+ continue;
702
+ }
703
+ ++cursorCounts[object.pressingCursorInstanceIndex];
704
+ }
705
+ if (beatmapSection.dragFingerIndex !== -1) {
706
+ // Remove the drag index to prevent it from being picked up into the detection.
707
+ cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
708
+ }
709
+ // This index will be used to detect if a section is 3-fingered.
710
+ // If the section is dragged, the dragged instance will be ignored,
711
+ // hence why the index is 1 less than nondragged section.
712
+ const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
713
+ // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
714
+ // to check if the section is 3-fingered.
715
+ const threeFingerRatio = cursorCounts
716
+ .slice(fingerSplitIndex)
717
+ .reduce((acc, value) => acc + value, 0) /
718
+ Math.max(cursorCounts
719
+ .slice(0, fingerSplitIndex)
720
+ .reduce((acc, value) => acc + value, 0), 1);
721
+ if (threeFingerRatio > this.threeFingerRatioThreshold) {
722
+ const threeFingeredObjectCount = cursorCounts
723
+ .slice(fingerSplitIndex)
724
+ .reduce((acc, value) => acc + value, 0);
725
+ const sectionObjectCount = beatmapSection.lastObjectIndex -
726
+ beatmapSection.firstObjectIndex +
727
+ 1;
728
+ const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
729
+ // We can ignore the first 3 (2 for drag) filled cursor instances
730
+ // since they are guaranteed not 3 finger.
731
+ const threeFingerCursorCounts = cursorCounts
732
+ .slice(fingerSplitIndex)
733
+ .filter((amount) => amount > 0);
734
+ // Finger factor applies more penalty if more fingers were used.
735
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
736
+ Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
737
+ // Length factor applies more penalty if there are more 3-fingered object.
738
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
739
+ this.nerfFactors.push({
740
+ strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
741
+ fingerFactor: fingerFactor,
742
+ lengthFactor: lengthFactor,
743
+ });
744
+ }
745
+ }
746
+ }
747
+ /**
748
+ * Calculates the final penalty.
749
+ */
750
+ calculateFinalPenalty() {
751
+ return this.nerfFactors.reduce((a, n) => a +
752
+ 0.015 *
753
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
754
+ }
755
+ }
756
+
757
+ /******************************************************************************
758
+ Copyright (c) Microsoft Corporation.
759
+
760
+ Permission to use, copy, modify, and/or distribute this software for any
761
+ purpose with or without fee is hereby granted.
762
+
763
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
764
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
765
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
766
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
767
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
768
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
769
+ PERFORMANCE OF THIS SOFTWARE.
770
+ ***************************************************************************** */
771
+ /* global Reflect, Promise, SuppressedError, Symbol */
772
+
773
+
774
+ function __awaiter(thisArg, _arguments, P, generator) {
775
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
776
+ return new (P || (P = Promise))(function (resolve, reject) {
777
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
778
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
779
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
780
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
781
+ });
782
+ }
783
+
784
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
785
+ var e = new Error(message);
786
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
787
+ };
788
+
315
789
  /**
316
790
  * Represents a replay data in an osu!droid replay.
317
791
  *
@@ -320,25 +794,6 @@ exports.HitResult = void 0;
320
794
  * This is used when analyzing replays using replay analyzer.
321
795
  */
322
796
  class ReplayData {
323
- replayVersion;
324
- folderName;
325
- fileName;
326
- hash;
327
- time;
328
- hit300k;
329
- hit100k;
330
- score;
331
- maxCombo;
332
- accuracy;
333
- isFullCombo;
334
- playerName;
335
- rawMods;
336
- rank;
337
- convertedMods;
338
- cursorMovement;
339
- hitObjectData;
340
- speedModification;
341
- forcedAR;
342
797
  constructor(values) {
343
798
  this.replayVersion = values.replayVersion;
344
799
  this.folderName = values.folderName;
@@ -357,8 +812,12 @@ class ReplayData {
357
812
  this.convertedMods = values.convertedMods || [];
358
813
  this.cursorMovement = values.cursorMovement;
359
814
  this.hitObjectData = values.hitObjectData;
360
- this.speedModification = values.speedModification || 1;
361
- this.forcedAR = values.forcedAR;
815
+ this.speedMultiplier = values.speedMultiplier || 1;
816
+ this.forceCS = values.forceCS;
817
+ this.forceAR = values.forceAR;
818
+ this.forceOD = values.forceOD;
819
+ this.forceHP = values.forceHP;
820
+ this.flashlightFollowDelay = values.flashlightFollowDelay;
362
821
  }
363
822
  }
364
823
 
@@ -366,112 +825,51 @@ class ReplayData {
366
825
  * Utility to check whether or not a beatmap is three-fingered.
367
826
  */
368
827
  class ThreeFingerChecker {
369
- /**
370
- * The beatmap that is being analyzed.
371
- */
372
- beatmap;
373
- /**
374
- * The data of the replay.
375
- */
376
- data;
377
- /**
378
- * The difficulty attributes of the beatmap.
379
- */
380
- difficultyAttributes;
381
- /**
382
- * The true scale of objects.
383
- */
384
- trueScale;
385
- /**
386
- * The distance threshold between cursors to assume that two cursors are
387
- * actually pressed with 1 finger in osu!pixels.
388
- *
389
- * This is used to prevent cases where a player would lift their finger
390
- * too fast to the point where the 4th cursor instance or beyond is recorded
391
- * as 1st, 2nd, or 3rd cursor instance.
392
- */
393
- cursorDistancingDistanceThreshold = 60;
394
- /**
395
- * The threshold for the amount of cursors that are assumed to be pressed
396
- * by a single finger.
397
- */
398
- cursorDistancingCountThreshold = 10;
399
- /**
400
- * The threshold for the time difference of cursors that are assumed to be pressed
401
- * by a single finger, in milliseconds.
402
- */
403
- cursorDistancingTimeThreshold = 1000;
404
- /**
405
- * The amount of notes that has a tap strain exceeding `strainThreshold`.
406
- */
407
- strainNoteCount;
408
- /**
409
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
410
- *
411
- * Increasing this number will increase detection accuracy, however
412
- * it also increases the chance of falsely flagged plays.
413
- */
414
- threeFingerRatioThreshold = 0.01;
415
- /**
416
- * Extended sections of the beatmap for drag detection.
417
- */
418
- beatmapSections = [];
419
- /**
420
- * This threshold is used to filter out accidental taps.
421
- *
422
- * Increasing this number makes the filtration more sensitive, however it
423
- * will also increase the chance of 3-fingered plays getting out from
424
- * being flagged.
425
- */
426
- accidentalTapThreshold = 400;
427
- /**
428
- * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
429
- */
430
- hitWindow;
431
- /**
432
- * A reprocessed break points to match right on object time.
433
- *
434
- * This is used to increase detection accuracy since break points do not start right at the
435
- * start of the hitobject before it and do not end right at the first hitobject after it.
436
- */
437
- breakPointAccurateTimes = [];
438
- /**
439
- * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
440
- *
441
- * Each index represents the cursor index.
442
- */
443
- downCursorInstances = [];
444
- /**
445
- * Nerf factors from all sections that were three-fingered.
446
- */
447
- nerfFactors = [];
448
- /**
449
- * Whether this score uses the Precise mod.
450
- */
451
- isPrecise;
452
828
  /**
453
829
  * @param beatmap The beatmap to analyze.
454
830
  * @param data The data of the replay.
455
831
  * @param difficultyAttributes The difficulty attributes of the beatmap.
456
832
  */
457
833
  constructor(beatmap, data, difficultyAttributes) {
834
+ /**
835
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
836
+ *
837
+ * Increasing this number will increase detection accuracy, however
838
+ * it also increases the chance of falsely flagged plays.
839
+ */
840
+ this.threeFingerRatioThreshold = 0.01;
841
+ /**
842
+ * Extended sections of the beatmap for drag detection.
843
+ */
844
+ this.beatmapSections = [];
845
+ /**
846
+ * A reprocessed break points to match right on object time.
847
+ *
848
+ * This is used to increase detection accuracy since break points do not start right at the
849
+ * start of the hitobject before it and do not end right at the first hitobject after it.
850
+ */
851
+ this.breakPointAccurateTimes = [];
852
+ /**
853
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
854
+ *
855
+ * Each index represents the cursor index.
856
+ */
857
+ this.downCursorInstances = [];
858
+ /**
859
+ * Nerf factors from all sections that were three-fingered.
860
+ */
861
+ this.nerfFactors = [];
458
862
  this.beatmap = beatmap;
459
863
  this.data = data;
460
864
  this.difficultyAttributes = difficultyAttributes;
461
- const stats = new osuBase.MapStats({
462
- od: this.beatmap.difficulty.od,
463
- mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
464
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
465
- }).calculate();
865
+ const od = osuBase.calculateDroidDifficultyStatistics({
866
+ overallDifficulty: beatmap.difficulty.od,
867
+ mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
868
+ convertOverallDifficulty: false,
869
+ }).overallDifficulty;
466
870
  this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
467
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
468
- this.strainNoteCount =
469
- this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
470
- const circleSize = new osuBase.MapStats({
471
- cs: this.beatmap.difficulty.cs,
472
- mods: this.difficultyAttributes.mods,
473
- }).calculate({ mode: osuBase.Modes.droid }).cs;
474
- this.trueScale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
871
+ this.hitWindow = new osuBase.DroidHitWindow(od);
872
+ this.hitObjects = beatmap.hitObjects.objects;
475
873
  }
476
874
  /**
477
875
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -492,7 +890,7 @@ class ThreeFingerChecker {
492
890
  * nerf factor, taking beatmap difficulty into account.
493
891
  */
494
892
  check() {
495
- if (this.strainNoteCount === 0) {
893
+ if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
496
894
  return { is3Finger: false, penalty: 1 };
497
895
  }
498
896
  this.getAccurateBreakPoints();
@@ -501,11 +899,6 @@ class ThreeFingerChecker {
501
899
  return { is3Finger: false, penalty: 1 };
502
900
  }
503
901
  this.getBeatmapSections();
504
- this.detectDragPlay();
505
- this.preventAccidentalTaps();
506
- if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
507
- return { is3Finger: false, penalty: 1 };
508
- }
509
902
  this.calculateNerfFactors();
510
903
  const finalPenalty = this.calculateFinalPenalty();
511
904
  return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
@@ -514,42 +907,31 @@ class ThreeFingerChecker {
514
907
  * Generates a new set of "accurate break points".
515
908
  *
516
909
  * This is done to increase detection accuracy since break points do not start right at the
517
- * start of the hitobject before it and do not end right at the first hitobject after it.
910
+ * end of the hitobject before it and do not end right at the first hitobject after it.
518
911
  */
519
912
  getAccurateBreakPoints() {
520
- const { objects } = this.beatmap.hitObjects;
521
913
  const objectData = this.data.hitObjectData;
522
914
  for (const breakPoint of this.beatmap.events.breaks) {
523
- const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
524
- let timeBefore = objects[beforeIndex].endTime;
525
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
526
- let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
527
- switch (objectData[beforeIndex].result) {
528
- case exports.HitResult.great:
529
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor300(this.isPrecise);
530
- break;
531
- case exports.HitResult.good:
532
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
533
- break;
534
- default:
535
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
915
+ const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
916
+ const objectBefore = this.hitObjects[beforeIndex];
917
+ const objectBeforeData = objectData[beforeIndex];
918
+ let timeBefore = objectBefore.endTime;
919
+ if (objectBefore instanceof osuBase.Circle) {
920
+ if (objectBeforeData.result !== exports.HitResult.miss) {
921
+ timeBefore += objectBeforeData.accuracy;
922
+ }
923
+ else {
924
+ timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
925
+ }
536
926
  }
537
- timeBefore += beforeIndexHitWindowLength;
538
927
  const afterIndex = beforeIndex + 1;
539
- let timeAfter = objects[afterIndex].startTime;
540
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
541
- let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
542
- switch (objectData[afterIndex].result) {
543
- case exports.HitResult.great:
544
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor300(this.isPrecise);
545
- break;
546
- case exports.HitResult.good:
547
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
548
- break;
549
- default:
550
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
928
+ const objectAfter = this.hitObjects[afterIndex];
929
+ const objectAfterData = objectData[afterIndex];
930
+ let timeAfter = this.hitObjects[afterIndex].startTime;
931
+ if (objectAfter instanceof osuBase.Circle &&
932
+ objectAfterData.result !== exports.HitResult.miss) {
933
+ timeAfter += objectAfterData.accuracy;
551
934
  }
552
- timeAfter += afterIndexHitWindowLength;
553
935
  this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
554
936
  startTime: timeBefore,
555
937
  endTime: timeAfter,
@@ -562,13 +944,14 @@ class ThreeFingerChecker {
562
944
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
563
945
  */
564
946
  filterCursorInstances() {
565
- const { objects } = this.beatmap.hitObjects;
566
947
  const objectData = this.data.hitObjectData;
567
948
  const firstObjectResult = objectData[0].result;
568
949
  const lastObjectResult = objectData.at(-1).result;
950
+ const firstObject = this.hitObjects[0];
951
+ const lastObject = this.hitObjects.at(-1);
569
952
  // For sliders, automatically set hit window length to be as lenient as possible.
570
953
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
571
- if (objects[0] instanceof osuBase.Circle) {
954
+ if (firstObject instanceof osuBase.Circle) {
572
955
  switch (firstObjectResult) {
573
956
  case exports.HitResult.great:
574
957
  firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -582,7 +965,7 @@ class ThreeFingerChecker {
582
965
  }
583
966
  // For sliders, automatically set hit window length to be as lenient as possible.
584
967
  let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
585
- if (objects.at(-1) instanceof osuBase.Circle) {
968
+ if (lastObject instanceof osuBase.Circle) {
586
969
  switch (lastObjectResult) {
587
970
  case exports.HitResult.great:
588
971
  lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -594,16 +977,17 @@ class ThreeFingerChecker {
594
977
  lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
595
978
  }
596
979
  }
980
+ else if (lastObject instanceof osuBase.Slider) {
981
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
982
+ }
597
983
  // These hit time uses hit window length as threshold.
598
984
  // This is because cursors aren't recorded exactly at hit time,
599
985
  // probably due to the game's behavior.
600
- const firstObjectHitTime = objects[0].startTime - firstObjectHitWindow;
601
- const lastObjectHitTime = objects.at(-1).startTime + lastObjectHitWindow;
602
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
603
- const cursorInstance = this.data.cursorMovement[i];
986
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
987
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
988
+ for (const cursorInstance of this.data.cursorMovement) {
604
989
  const validOccurrences = [];
605
- for (let j = 0; j < cursorInstance.occurrenceGroups.length; ++j) {
606
- const group = cursorInstance.occurrenceGroups[j];
990
+ for (const group of cursorInstance.occurrenceGroups) {
607
991
  if (group.startTime < firstObjectHitTime) {
608
992
  continue;
609
993
  }
@@ -624,24 +1008,93 @@ class ThreeFingerChecker {
624
1008
  * detect dragged sections and improve detection speed.
625
1009
  */
626
1010
  getBeatmapSections() {
1011
+ const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
627
1012
  for (const section of this.difficultyAttributes
628
1013
  .possibleThreeFingeredSections) {
629
- this.beatmapSections.push({
630
- ...section,
631
- isDragged: false,
632
- dragFingerIndex: -1,
633
- });
1014
+ const dragFingerIndex = this.findDragIndex(section);
1015
+ const objects = [];
1016
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
1017
+ objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
1018
+ }
1019
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
634
1020
  }
635
1021
  }
636
1022
  /**
637
- * Checks whether or not each beatmap sections is dragged.
638
- */
639
- detectDragPlay() {
640
- for (let i = 0; i < this.beatmapSections.length; ++i) {
641
- const dragIndex = this.checkDrag(this.beatmapSections[i]);
642
- this.beatmapSections[i].dragFingerIndex = dragIndex;
643
- this.beatmapSections[i].isDragged = dragIndex !== -1;
1023
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
1024
+ *
1025
+ * @param object The object to obtain the index for.
1026
+ * @param objectData The hit data of the object.
1027
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1028
+ * @param excludedIndices The cursor indices that should not be checked.
1029
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1030
+ */
1031
+ getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
1032
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1033
+ return {
1034
+ pressingCursorInstanceIndex: -1,
1035
+ pressingCursorIndex: -1,
1036
+ };
1037
+ }
1038
+ const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
1039
+ // Check for sliderbreaks and treat them as misses.
1040
+ if (object instanceof osuBase.Slider &&
1041
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
1042
+ return {
1043
+ pressingCursorInstanceIndex: -1,
1044
+ pressingCursorIndex: -1,
1045
+ };
1046
+ }
1047
+ // We are not directly using hit time to determine which cursor pressed the object
1048
+ // to account for time difference between hit registration and object judgement.
1049
+ let minHitTime = object.startTime;
1050
+ let maxHitTime = object.startTime;
1051
+ if (object instanceof osuBase.Circle) {
1052
+ let hitWindowGap = hitWindow50;
1053
+ switch (objectData.result) {
1054
+ case exports.HitResult.great:
1055
+ hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
1056
+ break;
1057
+ case exports.HitResult.good:
1058
+ hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
1059
+ break;
1060
+ }
1061
+ minHitTime -= hitWindowGap;
1062
+ maxHitTime += hitWindowGap;
1063
+ }
1064
+ else if (object instanceof osuBase.Slider) {
1065
+ minHitTime -= hitWindow50;
1066
+ maxHitTime += Math.min(hitWindow50, object.spanDuration);
1067
+ }
1068
+ const hitTime = object.startTime + objectData.accuracy;
1069
+ let nearestCursorInstanceIndex = null;
1070
+ let nearestCursorIndex = null;
1071
+ let nearestTime = Number.POSITIVE_INFINITY;
1072
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
1073
+ if (excludedCursorIndices.includes(i)) {
1074
+ continue;
1075
+ }
1076
+ const cursors = this.downCursorInstances[i];
1077
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
1078
+ const cursor = cursors[j];
1079
+ if (cursor.time < minHitTime) {
1080
+ continue;
1081
+ }
1082
+ if (cursor.time > maxHitTime) {
1083
+ break;
1084
+ }
1085
+ const deltaTime = Math.abs(hitTime - cursor.time);
1086
+ if (deltaTime > nearestTime) {
1087
+ break;
1088
+ }
1089
+ nearestCursorInstanceIndex = i;
1090
+ nearestCursorIndex = j;
1091
+ nearestTime = deltaTime;
1092
+ }
644
1093
  }
1094
+ return {
1095
+ pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
1096
+ pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
1097
+ };
645
1098
  }
646
1099
  /**
647
1100
  * Checks if a section is dragged and returns the index of the drag finger.
@@ -650,11 +1103,11 @@ class ThreeFingerChecker {
650
1103
  *
651
1104
  * @param section The section to check.
652
1105
  */
653
- checkDrag(section) {
654
- const { objects } = this.beatmap.hitObjects;
1106
+ findDragIndex(section) {
1107
+ var _a;
655
1108
  const objectData = this.data.hitObjectData;
656
- const firstObject = objects[section.firstObjectIndex];
657
- const lastObject = objects[section.lastObjectIndex];
1109
+ const firstObject = this.hitObjects[section.firstObjectIndex];
1110
+ const lastObject = this.hitObjects[section.lastObjectIndex];
658
1111
  let firstObjectMinHitTime = firstObject.startTime;
659
1112
  if (firstObject instanceof osuBase.Circle) {
660
1113
  switch (objectData[section.firstObjectIndex].result) {
@@ -668,7 +1121,7 @@ class ThreeFingerChecker {
668
1121
  firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
669
1122
  }
670
1123
  }
671
- else {
1124
+ else if (firstObject instanceof osuBase.Slider) {
672
1125
  firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
673
1126
  }
674
1127
  let lastObjectMaxHitTime = lastObject.startTime;
@@ -684,12 +1137,12 @@ class ThreeFingerChecker {
684
1137
  lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
685
1138
  }
686
1139
  }
687
- else {
688
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
1140
+ else if (lastObject instanceof osuBase.Slider) {
1141
+ lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
689
1142
  }
690
1143
  // Since there may be more than 1 cursor instance index,
691
1144
  // we check which cursor instance follows hitobjects all over.
692
- const cursorIndexes = [];
1145
+ const cursorIndices = [];
693
1146
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
694
1147
  const c = this.data.cursorMovement[i];
695
1148
  if (c.occurrenceGroups.length === 0) {
@@ -706,21 +1159,13 @@ class ThreeFingerChecker {
706
1159
  0) {
707
1160
  continue;
708
1161
  }
709
- cursorIndexes.push(i);
1162
+ cursorIndices.push(i);
710
1163
  }
711
- return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
712
- }
713
- /**
714
- * Finds the drag index of the section.
715
- *
716
- * @param sectionObjects The objects in the section.
717
- * @param sectionReplayObjectData The hitobject data of all objects in the section.
718
- * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
719
- */
720
- findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
1164
+ const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
1165
+ const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
721
1166
  const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
722
- for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
723
- let object = sectionObjects[i];
1167
+ for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
1168
+ const object = sectionObjects[i];
724
1169
  const objectData = sectionReplayObjectData[i];
725
1170
  if (object instanceof osuBase.Spinner ||
726
1171
  objectData.result === exports.HitResult.miss) {
@@ -731,210 +1176,101 @@ class ThreeFingerChecker {
731
1176
  objectData.accuracy === Math.floor(hitWindow50) + 13) {
732
1177
  continue;
733
1178
  }
734
- if (object.droidScale !== this.trueScale) {
735
- // Deep copy the instance so that we can assign scale.
736
- object = osuBase.Utils.deepCopy(object);
737
- object.droidScale = this.trueScale;
738
- }
739
1179
  const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
740
1180
  const hitTime = object.startTime + objectData.accuracy;
741
1181
  // Observe the cursor position at the object's hit time.
742
- for (let j = 0; j < cursorIndexes.length; ++j) {
743
- if (cursorIndexes[j] === -1) {
1182
+ for (let j = 0; j < cursorIndices.length; ++j) {
1183
+ if (cursorIndices[j] === -1) {
744
1184
  continue;
745
1185
  }
746
- const cursorData = this.data.cursorMovement[cursorIndexes[j]];
1186
+ const cursorData = this.data.cursorMovement[cursorIndices[j]];
747
1187
  const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
748
1188
  if (!cursorGroup) {
749
1189
  continue;
750
1190
  }
751
1191
  const cursors = cursorGroup.allOccurrences;
1192
+ let isInObject = false;
752
1193
  for (let k = 1; k < cursors.length; ++k) {
753
1194
  const cursor = cursors[k];
754
1195
  const prevCursor = cursors[k - 1];
755
- if (prevCursor.time < object.startTime - hitWindow50) {
756
- continue;
757
- }
758
- if (prevCursor.time > object.startTime + hitWindow50) {
759
- break;
760
- }
761
- let isInObject = false;
762
- switch (cursor.id) {
763
- case exports.MovementType.up:
764
- isInObject =
765
- prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
766
- break;
767
- case exports.MovementType.move:
768
- // Interpolate movement.
769
- for (let mSecPassed = prevCursor.time; !isInObject &&
770
- mSecPassed <=
771
- Math.min(cursor.time, object.startTime + hitWindow50); ++mSecPassed) {
772
- const t = (mSecPassed - prevCursor.time) /
1196
+ // Only consider cursor at interval prev.time <= hitTime <= current.time.
1197
+ if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
1198
+ switch (cursor.id) {
1199
+ case exports.MovementType.up:
1200
+ isInObject =
1201
+ prevCursor.position.getDistance(objectPosition) <= object.radius;
1202
+ break;
1203
+ case exports.MovementType.move: {
1204
+ // Interpolate movement.
1205
+ const t = (hitTime - prevCursor.time) /
773
1206
  (cursor.time - prevCursor.time);
774
1207
  const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
775
1208
  isInObject =
776
- objectPosition.getDistance(cursorPosition) <= object.getRadius(osuBase.Modes.droid);
1209
+ objectPosition.getDistance(cursorPosition) <= object.radius;
777
1210
  }
778
- }
779
- if (!isInObject) {
780
- cursorIndexes[j] = -1;
1211
+ }
1212
+ break;
781
1213
  }
782
1214
  }
1215
+ if (!isInObject) {
1216
+ cursorIndices[j] = -1;
1217
+ }
783
1218
  }
784
1219
  }
785
- return cursorIndexes.find((v) => v !== -1) ?? -1;
786
- }
787
- /**
788
- * Attempts to prevent accidental taps from being flagged.
789
- *
790
- * This detection will filter cursors that don't hit
791
- * any object in beatmap sections, thus eliminating any
792
- * unnecessary taps.
793
- */
794
- preventAccidentalTaps() {
795
- let filledCursorAmount = this.downCursorInstances.filter((v) => v.length > 0).length;
796
- if (filledCursorAmount <= 3) {
797
- return;
798
- }
799
- const { objects } = this.beatmap.hitObjects;
800
- const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.length, 0);
801
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
802
- if (filledCursorAmount <= 3) {
803
- break;
804
- }
805
- const cursorInstances = this.downCursorInstances[i];
806
- // Use an estimation for accidental tap threshold.
807
- if (cursorInstances.length <=
808
- Math.ceil(objects.length / this.accidentalTapThreshold) &&
809
- cursorInstances.length / totalCursorAmount <
810
- this.threeFingerRatioThreshold * 2) {
811
- --filledCursorAmount;
812
- cursorInstances.length = 0;
813
- }
814
- this.downCursorInstances[i] = cursorInstances;
815
- }
1220
+ return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
816
1221
  }
817
1222
  /**
818
1223
  * Creates nerf factors by scanning through objects.
819
- *
820
- * This check will ignore all objects with speed strain below `strainThreshold`.
821
1224
  */
822
1225
  calculateNerfFactors() {
823
- const { objects } = this.beatmap.hitObjects;
824
- const objectData = this.data.hitObjectData;
825
- // We only filter cursor instances that are above the strain threshold.
826
- // This minimalizes the amount of cursor instances to analyze.
827
1226
  for (const beatmapSection of this.beatmapSections) {
828
- const dragIndex = beatmapSection.dragFingerIndex;
829
- const startTime = objects[beatmapSection.firstObjectIndex].startTime +
830
- (objectData[beatmapSection.firstObjectIndex].result !==
831
- exports.HitResult.miss
832
- ? objectData[beatmapSection.firstObjectIndex].accuracy
833
- : -this.hitWindow.hitWindowFor50(this.isPrecise));
834
- const endTime = objects[beatmapSection.lastObjectIndex].endTime +
835
- (objectData[beatmapSection.lastObjectIndex].result !==
836
- exports.HitResult.miss
837
- ? objectData[beatmapSection.lastObjectIndex].accuracy
838
- : this.hitWindow.hitWindowFor50(this.isPrecise));
839
- const cursorAmounts = [];
840
- const cursorVectorTimes = [];
841
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
842
- // Do not include drag cursor instance.
843
- if (i === dragIndex) {
1227
+ const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1228
+ for (const object of beatmapSection.objects) {
1229
+ if (object.pressingCursorIndex === -1 ||
1230
+ object.pressingCursorInstanceIndex ===
1231
+ beatmapSection.dragFingerIndex) {
844
1232
  continue;
845
1233
  }
846
- const cursors = this.downCursorInstances[i];
847
- let amount = 0;
848
- for (let j = 0; j < cursors.length; ++j) {
849
- if (cursors[j].time >= startTime &&
850
- cursors[j].time <= endTime) {
851
- ++amount;
852
- cursorVectorTimes.push({
853
- vector: new osuBase.Vector2(cursors[j].position.x, cursors[j].position.y),
854
- time: cursors[j].time,
855
- });
856
- }
857
- }
858
- cursorAmounts.push(amount);
1234
+ ++cursorCounts[object.pressingCursorInstanceIndex];
1235
+ }
1236
+ if (beatmapSection.dragFingerIndex !== -1) {
1237
+ // Remove the drag index to prevent it from being picked up into the detection.
1238
+ cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
859
1239
  }
860
1240
  // This index will be used to detect if a section is 3-fingered.
861
1241
  // If the section is dragged, the dragged instance will be ignored,
862
1242
  // hence why the index is 1 less than nondragged section.
863
- const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
1243
+ const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
864
1244
  // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
865
1245
  // to check if the section is 3-fingered.
866
- const threeFingerRatio = cursorAmounts
1246
+ const threeFingerRatio = cursorCounts
867
1247
  .slice(fingerSplitIndex)
868
1248
  .reduce((acc, value) => acc + value, 0) /
869
- cursorAmounts
1249
+ Math.max(cursorCounts
870
1250
  .slice(0, fingerSplitIndex)
1251
+ .reduce((acc, value) => acc + value, 0), 1);
1252
+ if (threeFingerRatio > this.threeFingerRatioThreshold) {
1253
+ const threeFingeredObjectCount = cursorCounts
1254
+ .slice(fingerSplitIndex)
871
1255
  .reduce((acc, value) => acc + value, 0);
872
- const similarPresses = [];
873
- for (const cursorVectorTime of cursorVectorTimes) {
874
- const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
875
- this.cursorDistancingDistanceThreshold);
876
- if (pressIndex !== -1) {
877
- if (cursorVectorTime.time -
878
- similarPresses[pressIndex].lastTime >=
879
- this.cursorDistancingTimeThreshold) {
880
- similarPresses.splice(pressIndex, 1);
881
- similarPresses.push({
882
- vector: cursorVectorTime.vector,
883
- count: 1,
884
- lastTime: cursorVectorTime.time,
885
- });
886
- continue;
887
- }
888
- similarPresses[pressIndex].vector = cursorVectorTime.vector;
889
- similarPresses[pressIndex].lastTime = cursorVectorTime.time;
890
- ++similarPresses[pressIndex].count;
891
- }
892
- else {
893
- similarPresses.push({
894
- vector: cursorVectorTime.vector,
895
- count: 1,
896
- lastTime: cursorVectorTime.time,
897
- });
898
- }
899
- }
900
- // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
901
- const validPresses = similarPresses
902
- .filter((v) => v.count >= this.cursorDistancingCountThreshold)
903
- .sort((a, b) => b.count - a.count)
904
- .slice(2);
905
- // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
906
- if ((threeFingerRatio > this.threeFingerRatioThreshold &&
907
- cursorAmounts.filter((v) => v > 1).length >
908
- fingerSplitIndex) ||
909
- validPresses.length > 0) {
910
- // Strain factor
911
- const objectCount = beatmapSection.lastObjectIndex -
1256
+ const sectionObjectCount = beatmapSection.lastObjectIndex -
912
1257
  beatmapSection.firstObjectIndex +
913
1258
  1;
1259
+ const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
914
1260
  // We can ignore the first 3 (2 for drag) filled cursor instances
915
1261
  // since they are guaranteed not 3 finger.
916
- const threeFingerCursorAmounts = cursorAmounts
1262
+ const threeFingerCursorCounts = cursorCounts
917
1263
  .slice(fingerSplitIndex)
918
1264
  .filter((amount) => amount > 0);
919
1265
  // Finger factor applies more penalty if more fingers were used.
920
- const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
921
- ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
922
- Math.pow(((index + 1) * value * objectCount) /
923
- this.strainNoteCount, 0.9), 1)
924
- : Math.pow(validPresses.reduce((acc, value, index) => acc +
925
- Math.pow(((index + 1) *
926
- (value.count /
927
- (this
928
- .cursorDistancingCountThreshold *
929
- 2)) *
930
- objectCount) /
931
- this.strainNoteCount, 0.2), 1), 0.2);
1266
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1267
+ Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
932
1268
  // Length factor applies more penalty if there are more 3-fingered object.
933
- const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
1269
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
934
1270
  this.nerfFactors.push({
935
- strainFactor: Math.max(1, beatmapSection.sumStrain),
936
- fingerFactor,
937
- lengthFactor,
1271
+ strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
1272
+ fingerFactor: fingerFactor,
1273
+ lengthFactor: lengthFactor,
938
1274
  });
939
1275
  }
940
1276
  }
@@ -943,10 +1279,9 @@ class ThreeFingerChecker {
943
1279
  * Calculates the final penalty.
944
1280
  */
945
1281
  calculateFinalPenalty() {
946
- return (1 +
947
- this.nerfFactors.reduce((a, n) => a +
948
- 0.015 *
949
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
1282
+ return this.nerfFactors.reduce((a, n) => a +
1283
+ 0.015 *
1284
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
950
1285
  }
951
1286
  }
952
1287
 
@@ -954,49 +1289,6 @@ class ThreeFingerChecker {
954
1289
  * Contains information about which cursor index hits a hitobject.
955
1290
  */
956
1291
  class IndexedHitObject {
957
- /**
958
- * The cursor index that hits the hitobject.
959
- *
960
- * If -1, the detection was unable to find any cursor that attempted to hit
961
- * the hitobject or it did not meet the criteria for detection.
962
- */
963
- cursorIndex;
964
- /**
965
- * The group index of the cursor within the cursor index that hits the hitobject.
966
- *
967
- * If -1, the detection was unable to find any cursor that attempted to hit
968
- * the hitobject or it did not meet the criteria for detection.
969
- */
970
- groupIndex;
971
- /**
972
- * The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
973
- *
974
- * If -1, the detection was unable to find any cursor that attempted to hit
975
- * the hitobject or it did not meet the criteria for detection.
976
- */
977
- occurrenceIndex;
978
- /**
979
- * The angle of the movement of the cursor towards the next hitobject.
980
- */
981
- angle;
982
- /**
983
- * If this is a slider, whether the slider was cheesed.
984
- */
985
- sliderCheesed = false;
986
- /**
987
- * The underlying difficulty hitobject.
988
- */
989
- object;
990
- /**
991
- * The position of the cursor at the end of this hitobject.
992
- *
993
- * Will be altered during detection.
994
- */
995
- endCursorPosition;
996
- /**
997
- * Whether the hitobject is likely two-handed.
998
- */
999
- is2Handed;
1000
1292
  /**
1001
1293
  * @param object The underlying difficulty hitobject.
1002
1294
  * @param cursorIndex The cursor index that moves towards the hitobject.
@@ -1005,6 +1297,10 @@ class IndexedHitObject {
1005
1297
  * @param angle The angle of the movement of the cursor that moves towards the hitobject.
1006
1298
  */
1007
1299
  constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
1300
+ /**
1301
+ * If this is a slider, whether the slider was cheesed.
1302
+ */
1303
+ this.sliderCheesed = false;
1008
1304
  this.object = object;
1009
1305
  this.cursorIndex = cursorIndex;
1010
1306
  this.groupIndex = groupIndex;
@@ -1020,40 +1316,24 @@ class IndexedHitObject {
1020
1316
  * Utility to check whether or not a beatmap is two-handed.
1021
1317
  */
1022
1318
  class TwoHandChecker {
1023
- /**
1024
- * The difficulty calculator that is being analyzed.
1025
- */
1026
- calculator;
1027
- /**
1028
- * The data of the replay.
1029
- */
1030
- data;
1031
- /**
1032
- * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1033
- */
1034
- indexedHitObjects = [];
1035
- /**
1036
- * The osu!droid hitwindow of the analyzed beatmap.
1037
- */
1038
- hitWindow;
1039
- /**
1040
- * The 50 osu!droid hit window of the analyzed beatmap.
1041
- */
1042
- hitWindow50;
1043
1319
  // private csvString: string;
1044
1320
  /**
1045
1321
  * @param calculator The difficulty calculator to analyze.
1046
1322
  * @param data The data of the replay.
1047
1323
  */
1048
1324
  constructor(calculator, data) {
1325
+ /**
1326
+ * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1327
+ */
1328
+ this.indexedHitObjects = [];
1049
1329
  this.calculator = calculator;
1050
1330
  this.data = data;
1051
- const stats = new osuBase.MapStats({
1052
- od: this.calculator.beatmap.difficulty.od,
1053
- mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
1054
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1055
- }).calculate();
1056
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1331
+ const od = osuBase.calculateDroidDifficultyStatistics({
1332
+ overallDifficulty: calculator.beatmap.difficulty.od,
1333
+ mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1334
+ convertOverallDifficulty: false,
1335
+ }).overallDifficulty;
1336
+ this.hitWindow = new osuBase.DroidHitWindow(od);
1057
1337
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1058
1338
  // this.csvString = `Mods,${
1059
1339
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
@@ -1236,9 +1516,8 @@ class TwoHandChecker {
1236
1516
  const prevToCurrentMovement = object
1237
1517
  .getStackedPosition(osuBase.Modes.droid)
1238
1518
  .subtract(prevObjectEndPosition);
1239
- const radius = object.getRadius(osuBase.Modes.droid);
1240
1519
  // Don't consider objects that are too close to each other.
1241
- if (prevToCurrentMovement.length <= radius) {
1520
+ if (prevToCurrentMovement.length <= object.radius) {
1242
1521
  return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1243
1522
  }
1244
1523
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
@@ -1367,7 +1646,6 @@ class TwoHandChecker {
1367
1646
  cursorTime: object.startTime,
1368
1647
  };
1369
1648
  }
1370
- const radius = object.getRadius(osuBase.Modes.droid);
1371
1649
  let hitWindow = this.hitWindow50;
1372
1650
  const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1373
1651
  // For sliders, set the hit window to as lenient as possible.
@@ -1382,7 +1660,7 @@ class TwoHandChecker {
1382
1660
  }
1383
1661
  }
1384
1662
  // TODO: what to do for head sliderbreaks?
1385
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1663
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1386
1664
  let nearestCursorIndex = 0;
1387
1665
  let nearestGroupIndex = 0;
1388
1666
  let nearestCursorGroupIndex = 0;
@@ -1390,12 +1668,12 @@ class TwoHandChecker {
1390
1668
  const minimumActiveTime = object.startTime - hitWindow;
1391
1669
  const maximumActiveTime = object.startTime + hitWindow;
1392
1670
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1393
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1671
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1394
1672
  break;
1395
1673
  }
1396
1674
  const cursorData = this.data.cursorMovement[i];
1397
1675
  for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
1398
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1676
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1399
1677
  break;
1400
1678
  }
1401
1679
  const cursorGroup = cursorData.occurrenceGroups[j];
@@ -1407,7 +1685,8 @@ class TwoHandChecker {
1407
1685
  }
1408
1686
  // Validate the down press first.
1409
1687
  const { down } = cursorGroup;
1410
- if (down.position.getDistance(objectPosition) <= radius &&
1688
+ if (down.position.getDistance(objectPosition) <=
1689
+ object.radius &&
1411
1690
  Math.abs(down.time - object.startTime) <= hitWindow) {
1412
1691
  if (objectIndex > 0) {
1413
1692
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
@@ -1478,12 +1757,13 @@ class TwoHandChecker {
1478
1757
  nearestCursorGroupIndex = k;
1479
1758
  nearestCursorTime = cursorGroup.down.time;
1480
1759
  }
1481
- if (distance <= radius) {
1760
+ if (distance <= object.radius) {
1482
1761
  break;
1483
1762
  }
1484
1763
  }
1485
1764
  }
1486
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1765
+ if (nearestPosition.getDistance(objectPosition) <=
1766
+ object.radius) {
1487
1767
  break;
1488
1768
  }
1489
1769
  }
@@ -1531,7 +1811,7 @@ class TwoHandChecker {
1531
1811
  else {
1532
1812
  objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
1533
1813
  }
1534
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1814
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1535
1815
  let nearestCursorIndex = 0;
1536
1816
  let nearestGroupIndex = 0;
1537
1817
  let nearestCursorGroupIndex = 0;
@@ -1631,22 +1911,6 @@ class TwoHandChecker {
1631
1911
  * Utility to check whether relevant sliders in a beatmap are cheesed.
1632
1912
  */
1633
1913
  class SliderCheeseChecker {
1634
- /**
1635
- * The beatmap that is being analyzed.
1636
- */
1637
- beatmap;
1638
- /**
1639
- * The data of the replay.
1640
- */
1641
- data;
1642
- /**
1643
- * The difficulty attributes of the beatmap.
1644
- */
1645
- difficultyAttributes;
1646
- /**
1647
- * The 50 osu!droid hit window of the analyzed beatmap.
1648
- */
1649
- hitWindow50;
1650
1914
  /**
1651
1915
  * @param beatmap The beatmap to analyze.
1652
1916
  * @param data The data of the replay.
@@ -1656,18 +1920,21 @@ class SliderCheeseChecker {
1656
1920
  this.beatmap = beatmap;
1657
1921
  this.data = data;
1658
1922
  this.difficultyAttributes = difficultyAttributes;
1659
- const stats = new osuBase.MapStats({
1660
- od: this.beatmap.difficulty.od,
1661
- mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
1662
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1663
- }).calculate();
1664
- this.hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1923
+ const od = osuBase.calculateDroidDifficultyStatistics({
1924
+ overallDifficulty: beatmap.difficulty.od,
1925
+ mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1926
+ convertOverallDifficulty: false,
1927
+ }).overallDifficulty;
1928
+ this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1665
1929
  }
1666
1930
  /**
1667
1931
  * Checks if relevant sliders in the given beatmap was cheesed.
1668
1932
  */
1669
1933
  check() {
1670
- if (this.difficultyAttributes.difficultSliders.length === 0) {
1934
+ if (this.difficultyAttributes.difficultSliders.length === 0 ||
1935
+ (this.difficultyAttributes.sliderFactor === 1 &&
1936
+ this.difficultyAttributes.flashlightSliderFactor === 1 &&
1937
+ this.difficultyAttributes.visualSliderFactor === 1)) {
1671
1938
  return {
1672
1939
  aimPenalty: 1,
1673
1940
  flashlightPenalty: 1,
@@ -1681,15 +1948,11 @@ class SliderCheeseChecker {
1681
1948
  * Checks for sliders that were cheesed.
1682
1949
  */
1683
1950
  checkSliderCheesing() {
1951
+ const { objects } = this.beatmap.hitObjects;
1684
1952
  const cheesedDifficultyRatings = [];
1685
1953
  // Current loop indices are stored for efficiency.
1686
1954
  const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1687
- const circleSize = new osuBase.MapStats({
1688
- cs: this.beatmap.difficulty.cs,
1689
- mods: this.difficultyAttributes.mods,
1690
- }).calculate({ mode: osuBase.Modes.droid }).cs;
1691
- const scale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
1692
- const acceptableRadius = 128 * scale;
1955
+ const acceptableRadius = objects[0].radius * 2;
1693
1956
  // Sort difficult sliders by index so that cursor loop indices work properly.
1694
1957
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
1695
1958
  .slice()
@@ -1698,18 +1961,19 @@ class SliderCheeseChecker {
1698
1961
  continue;
1699
1962
  }
1700
1963
  const objectData = this.data.hitObjectData[difficultSlider.index];
1701
- // If a slider break occurs, we disregard the check for that slider.
1702
- if (objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1964
+ // If a miss or slider break occurs, we disregard the check for that slider.
1965
+ if (objectData.result === exports.HitResult.miss ||
1966
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1703
1967
  continue;
1704
1968
  }
1705
- let object = (this.beatmap.hitObjects.objects[difficultSlider.index]);
1706
- if (object.droidScale !== scale) {
1707
- // Deep clone the object so that we can assign scale properly.
1708
- object = osuBase.Utils.deepCopy(object);
1709
- object.droidScale = scale;
1710
- }
1969
+ const object = objects[difficultSlider.index];
1711
1970
  const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1712
- // Get the closest tap distance across all cursor.
1971
+ // These time boundaries should consider the delta time between the previous and next
1972
+ // object as well as their hit accuracy. However, they are somewhat complicated to
1973
+ // compute and the accuracy gain is small. As such, let's settle with 50 hit window.
1974
+ const minTimeLimit = object.startTime - this.hitWindow50;
1975
+ const maxTimeLimit = object.startTime + this.hitWindow50;
1976
+ // Get the closest tap distance across all cursors.
1713
1977
  const closestDistances = [];
1714
1978
  const closestGroupIndices = [];
1715
1979
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
@@ -1718,24 +1982,72 @@ class SliderCheeseChecker {
1718
1982
  let closestIndex = cursorGroups.length;
1719
1983
  for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
1720
1984
  const group = cursorGroups[j];
1721
- if (group.startTime < object.startTime - this.hitWindow50) {
1985
+ if (group.endTime < minTimeLimit) {
1722
1986
  continue;
1723
1987
  }
1724
- if (group.startTime > object.startTime + this.hitWindow50) {
1988
+ if (group.startTime > maxTimeLimit) {
1725
1989
  break;
1726
1990
  }
1727
- const distance = group.down.position.getDistance(objectStartPosition);
1728
- if (closestDistance > distance) {
1729
- closestDistance = distance;
1730
- closestIndex = j;
1991
+ if (group.startTime >= minTimeLimit) {
1992
+ const distance = group.down.position.getDistance(objectStartPosition);
1993
+ if (closestDistance > distance) {
1994
+ closestDistance = distance;
1995
+ closestIndex = j;
1996
+ }
1997
+ if (closestDistance <= acceptableRadius / 2) {
1998
+ break;
1999
+ }
2000
+ }
2001
+ // Normally, we check if there are cursor presses within the group's active time.
2002
+ // However, some funky workarounds are used throughout the game for replays, so
2003
+ // for the time being we only check for cursor distances across the group.
2004
+ const { allOccurrences } = group;
2005
+ for (let k = 1; k < allOccurrences.length; ++k) {
2006
+ const occurrence = allOccurrences[k];
2007
+ const prevOccurrence = allOccurrences[k - 1];
2008
+ let distance = Number.POSITIVE_INFINITY;
2009
+ switch (occurrence.id) {
2010
+ case exports.MovementType.up:
2011
+ distance =
2012
+ prevOccurrence.position.getDistance(objectStartPosition);
2013
+ break;
2014
+ case exports.MovementType.move:
2015
+ for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
2016
+ Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
2017
+ const t = (mSecPassed - prevOccurrence.time) /
2018
+ (occurrence.time - prevOccurrence.time);
2019
+ const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
2020
+ distance =
2021
+ cursorPosition.getDistance(objectStartPosition);
2022
+ if (closestDistance > distance) {
2023
+ closestDistance = distance;
2024
+ closestIndex = j;
2025
+ }
2026
+ if (closestDistance <=
2027
+ acceptableRadius / 2) {
2028
+ break;
2029
+ }
2030
+ }
2031
+ }
2032
+ if (closestDistance > distance) {
2033
+ closestDistance = distance;
2034
+ closestIndex = j;
2035
+ }
2036
+ if (closestDistance <= acceptableRadius / 2) {
2037
+ break;
2038
+ }
1731
2039
  }
1732
2040
  }
1733
2041
  closestDistances.push(closestDistance);
1734
2042
  closestGroupIndices.push(closestIndex);
2043
+ if (cursorLoopIndices[i] > 0) {
2044
+ // Decrement the index. The previous group may also have a role on the next slider.
2045
+ --cursorLoopIndices[i];
2046
+ }
1735
2047
  }
1736
2048
  const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
1737
2049
  const closestDistance = closestDistances[cursorIndex];
1738
- if (closestDistance > acceptableRadius) {
2050
+ if (closestDistance > acceptableRadius / 2) {
1739
2051
  cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
1740
2052
  continue;
1741
2053
  }
@@ -1752,12 +2064,7 @@ class SliderCheeseChecker {
1752
2064
  if (!tickWasHit) {
1753
2065
  continue;
1754
2066
  }
1755
- let nestedObject = object.nestedHitObjects[i];
1756
- if (nestedObject.droidScale !== scale) {
1757
- // Deep clone the object so that we can assign scale properly
1758
- nestedObject = osuBase.Utils.deepCopy(nestedObject);
1759
- nestedObject.droidScale = scale;
1760
- }
2067
+ const nestedObject = object.nestedHitObjects[i];
1761
2068
  const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1762
2069
  while (occurrenceLoopIndex < allOccurrences.length &&
1763
2070
  allOccurrences[occurrenceLoopIndex].time <
@@ -1794,17 +2101,17 @@ class SliderCheeseChecker {
1794
2101
  * Calculates the slider cheese penalty.
1795
2102
  */
1796
2103
  calculateSliderCheesePenalty(cheesedDifficultyRatings) {
1797
- const summedDifficultyRating = cheesedDifficultyRatings.reduce((a, v) => a + v, 0);
2104
+ const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
1798
2105
  return {
1799
- aimPenalty: Math.pow(1 -
2106
+ aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
1800
2107
  summedDifficultyRating *
1801
- this.difficultyAttributes.sliderFactor, 3),
1802
- flashlightPenalty: Math.pow(1 -
2108
+ this.difficultyAttributes.sliderFactor, 2)),
2109
+ flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
1803
2110
  summedDifficultyRating *
1804
- this.difficultyAttributes.flashlightSliderFactor, 3),
1805
- visualPenalty: Math.pow(1 -
2111
+ this.difficultyAttributes.flashlightSliderFactor, 2)),
2112
+ visualPenalty: Math.max(this.difficultyAttributes.visualSliderFactor, Math.pow(1 -
1806
2113
  summedDifficultyRating *
1807
- this.difficultyAttributes.visualSliderFactor, 3),
2114
+ this.difficultyAttributes.visualSliderFactor, 2)),
1808
2115
  };
1809
2116
  }
1810
2117
  }
@@ -1817,77 +2124,57 @@ class SliderCheeseChecker {
1817
2124
  * Once analyzed, the result can be accessed via the `data` property.
1818
2125
  */
1819
2126
  class ReplayAnalyzer {
1820
- /**
1821
- * The score ID of the replay.
1822
- */
1823
- scoreID;
1824
- /**
1825
- * The original odr file of the replay.
1826
- */
1827
- originalODR = null;
1828
- /**
1829
- * The fixed odr file of the replay.
1830
- */
1831
- fixedODR = null;
1832
- /**
1833
- * Whether or not the play is considered using >=3 finger abuse.
1834
- */
1835
- is3Finger;
1836
- /**
1837
- * Whether or not the play is considered 2-handed.
1838
- */
1839
- is2Hand;
1840
- /**
1841
- * The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
1842
- */
1843
- beatmap;
1844
- /**
1845
- * The difficulty attributes of the beatmap.
1846
- */
1847
- difficultyAttributes;
1848
- /**
1849
- * The results of the analyzer. `null` when initialized.
1850
- */
1851
- data = null;
1852
- /**
1853
- * Penalty value used to penalize dpp for 2-hand.
1854
- */
1855
- aimPenalty = 1;
1856
- /**
1857
- * Penalty value used to penalize dpp for 3 finger abuse.
1858
- */
1859
- tapPenalty = 1;
1860
- /**
1861
- * Penalty values used to penalize dpp for slider cheesing.
1862
- */
1863
- sliderCheesePenalty = {
1864
- aimPenalty: 1,
1865
- flashlightPenalty: 1,
1866
- visualPenalty: 1,
1867
- };
1868
- /**
1869
- * Whether this replay has been checked against 3 finger usage.
1870
- */
1871
- hasBeenCheckedFor3Finger = false;
1872
- /**
1873
- * Whether this replay has been checked against 2 hand usage.
1874
- */
1875
- hasBeenCheckedFor2Hand = false;
1876
- /**
1877
- * Whether this repla has been checked against slider cheesing.
1878
- */
1879
- hasBeenCheckedForSliderCheesing = false;
1880
- /**
1881
- * The amount of two-handed objects.
1882
- */
1883
- twoHandedNoteCount = 0;
1884
- // Sizes of primitive data types in Java (in bytes)
1885
- BYTE_LENGTH = 1;
1886
- SHORT_LENGTH = 2;
1887
- INT_LENGTH = 4;
1888
- FLOAT_LENGTH = 4;
1889
- LONG_LENGTH = 8;
1890
2127
  constructor(values) {
2128
+ /**
2129
+ * The original odr file of the replay.
2130
+ */
2131
+ this.originalODR = null;
2132
+ /**
2133
+ * The fixed odr file of the replay.
2134
+ */
2135
+ this.fixedODR = null;
2136
+ /**
2137
+ * The results of the analyzer. `null` when initialized.
2138
+ */
2139
+ this.data = null;
2140
+ /**
2141
+ * Penalty value used to penalize dpp for 2-hand.
2142
+ */
2143
+ this.aimPenalty = 1;
2144
+ /**
2145
+ * Penalty value used to penalize dpp for 3 finger abuse.
2146
+ */
2147
+ this.tapPenalty = 1;
2148
+ /**
2149
+ * Penalty values used to penalize dpp for slider cheesing.
2150
+ */
2151
+ this.sliderCheesePenalty = {
2152
+ aimPenalty: 1,
2153
+ flashlightPenalty: 1,
2154
+ visualPenalty: 1,
2155
+ };
2156
+ /**
2157
+ * Whether this replay has been checked against 3 finger usage.
2158
+ */
2159
+ this.hasBeenCheckedFor3Finger = false;
2160
+ /**
2161
+ * Whether this replay has been checked against 2 hand usage.
2162
+ */
2163
+ this.hasBeenCheckedFor2Hand = false;
2164
+ /**
2165
+ * Whether this replay has been checked against slider cheesing.
2166
+ */
2167
+ this.hasBeenCheckedForSliderCheesing = false;
2168
+ /**
2169
+ * The amount of two-handed objects.
2170
+ */
2171
+ this.twoHandedNoteCount = 0;
2172
+ // Sizes of primitive data types in Java (in bytes)
2173
+ this.BYTE_LENGTH = 1;
2174
+ this.SHORT_LENGTH = 2;
2175
+ this.INT_LENGTH = 4;
2176
+ this.FLOAT_LENGTH = 4;
2177
+ this.LONG_LENGTH = 8;
1891
2178
  this.scoreID = values.scoreID;
1892
2179
  this.beatmap = values.map;
1893
2180
  this.difficultyAttributes = values.difficultyAttributes;
@@ -1898,35 +2185,39 @@ class ReplayAnalyzer {
1898
2185
  /**
1899
2186
  * Analyzes a replay.
1900
2187
  */
1901
- async analyze() {
1902
- if (!this.originalODR && !this.fixedODR) {
1903
- this.originalODR = await this.downloadReplay();
1904
- }
1905
- if (!this.originalODR) {
1906
- return this;
1907
- }
1908
- if (!this.fixedODR) {
1909
- this.fixedODR = await this.decompress().catch(() => null);
1910
- }
1911
- if (!this.fixedODR) {
2188
+ analyze() {
2189
+ return __awaiter(this, void 0, void 0, function* () {
2190
+ if (!this.originalODR && !this.fixedODR) {
2191
+ this.originalODR = yield this.downloadReplay();
2192
+ }
2193
+ if (!this.originalODR) {
2194
+ return this;
2195
+ }
2196
+ if (!this.fixedODR) {
2197
+ this.fixedODR = yield this.decompress().catch(() => null);
2198
+ }
2199
+ if (!this.fixedODR) {
2200
+ return this;
2201
+ }
2202
+ this.parseReplay();
1912
2203
  return this;
1913
- }
1914
- this.parseReplay();
1915
- return this;
2204
+ });
1916
2205
  }
1917
2206
  /**
1918
2207
  * Downloads the given score ID's replay.
1919
2208
  */
1920
- async downloadReplay() {
1921
- const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1922
- .setRequireAPIkey(false)
1923
- .setEndpoint("upload")
1924
- .addParameter("", `${this.scoreID}.odr`);
1925
- const result = await apiRequestBuilder.sendRequest();
1926
- if (result.statusCode !== 200) {
1927
- return null;
1928
- }
1929
- return result.data;
2209
+ downloadReplay() {
2210
+ return __awaiter(this, void 0, void 0, function* () {
2211
+ const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
2212
+ .setRequireAPIkey(false)
2213
+ .setEndpoint("upload")
2214
+ .addParameter("", `${this.scoreID}.odr`);
2215
+ const result = yield apiRequestBuilder.sendRequest();
2216
+ if (result.statusCode !== 200) {
2217
+ return null;
2218
+ }
2219
+ return result.data;
2220
+ });
1930
2221
  }
1931
2222
  /**
1932
2223
  * Decompresses a replay.
@@ -1940,15 +2231,15 @@ class ReplayAnalyzer {
1940
2231
  stream.push(null);
1941
2232
  stream
1942
2233
  .pipe(unzipper.Parse())
1943
- .on("entry", async (entry) => {
2234
+ .on("entry", (entry) => __awaiter(this, void 0, void 0, function* () {
1944
2235
  const fileName = entry.path;
1945
2236
  if (fileName === "data") {
1946
- return resolve(await entry.buffer());
2237
+ return resolve(yield entry.buffer());
1947
2238
  }
1948
2239
  else {
1949
2240
  entry.autodrain();
1950
2241
  }
1951
- })
2242
+ }))
1952
2243
  .on("error", (e) => {
1953
2244
  setTimeout(() => reject(e), 2000);
1954
2245
  });
@@ -1958,6 +2249,10 @@ class ReplayAnalyzer {
1958
2249
  * Parses a replay after being downloaded and converted to a buffer.
1959
2250
  */
1960
2251
  parseReplay() {
2252
+ var _a, _b, _c;
2253
+ if (!this.fixedODR) {
2254
+ return;
2255
+ }
1961
2256
  // javaDeserialization can only somewhat parse some string field
1962
2257
  // the rest will be a buffer that we need to manually parse
1963
2258
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1965,7 +2260,7 @@ class ReplayAnalyzer {
1965
2260
  try {
1966
2261
  rawObject = javaDeserialization__namespace.parse(this.fixedODR);
1967
2262
  }
1968
- catch {
2263
+ catch (_d) {
1969
2264
  return;
1970
2265
  }
1971
2266
  const resultObject = {
@@ -1975,29 +2270,16 @@ class ReplayAnalyzer {
1975
2270
  hash: rawObject[3],
1976
2271
  cursorMovement: [],
1977
2272
  hitObjectData: [],
2273
+ accuracy: new osuBase.Accuracy({ n300: 0 }),
2274
+ rank: "",
1978
2275
  };
1979
- if (resultObject.replayVersion >= 3) {
1980
- resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
1981
- resultObject.hit300k = rawObject[4].readInt32BE(8);
1982
- resultObject.hit100k = rawObject[4].readInt32BE(16);
1983
- resultObject.score = rawObject[4].readInt32BE(32);
1984
- resultObject.maxCombo = rawObject[4].readInt32BE(36);
1985
- resultObject.accuracy = new osuBase.Accuracy({
1986
- n300: rawObject[4].readInt32BE(12),
1987
- n100: rawObject[4].readInt32BE(20),
1988
- n50: rawObject[4].readInt32BE(24),
1989
- nmiss: rawObject[4].readInt32BE(28),
1990
- });
1991
- resultObject.isFullCombo = !!rawObject[4][44];
1992
- resultObject.playerName = rawObject[5];
1993
- resultObject.rawMods = rawObject[6].elements;
1994
- resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
1995
- // Determine rank
2276
+ const determineRank = () => {
2277
+ var _a, _b;
1996
2278
  const totalHits = resultObject.accuracy.n300 +
1997
2279
  resultObject.accuracy.n100 +
1998
2280
  resultObject.accuracy.n50 +
1999
2281
  resultObject.accuracy.nmiss;
2000
- const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
2282
+ const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2001
2283
  const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2002
2284
  switch (true) {
2003
2285
  case resultObject.accuracy.value() === 1:
@@ -2032,13 +2314,53 @@ class ReplayAnalyzer {
2032
2314
  default:
2033
2315
  resultObject.rank = "D";
2034
2316
  }
2317
+ };
2318
+ if (resultObject.replayVersion >= 3) {
2319
+ resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
2320
+ resultObject.hit300k = rawObject[4].readInt32BE(8);
2321
+ resultObject.hit100k = rawObject[4].readInt32BE(16);
2322
+ resultObject.score = rawObject[4].readInt32BE(32);
2323
+ resultObject.maxCombo = rawObject[4].readInt32BE(36);
2324
+ resultObject.accuracy = new osuBase.Accuracy({
2325
+ n300: rawObject[4].readInt32BE(12),
2326
+ n100: rawObject[4].readInt32BE(20),
2327
+ n50: rawObject[4].readInt32BE(24),
2328
+ nmiss: rawObject[4].readInt32BE(28),
2329
+ });
2330
+ resultObject.isFullCombo = !!rawObject[4][44];
2331
+ resultObject.playerName = rawObject[5];
2332
+ resultObject.rawMods = rawObject[6].elements;
2333
+ resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
2334
+ determineRank();
2035
2335
  }
2036
2336
  if (resultObject.replayVersion >= 4) {
2037
- const s = rawObject[7].split("|");
2038
- resultObject.speedModification =
2039
- parseFloat(s[0].replace("x", "")) || 1;
2040
- if (s.length > 1) {
2041
- resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
2337
+ const str = rawObject[7].split("|");
2338
+ for (const s of str) {
2339
+ switch (true) {
2340
+ // Forced stats
2341
+ case s.startsWith("CS"):
2342
+ resultObject.forceCS = parseFloat(s.replace("CS", ""));
2343
+ break;
2344
+ case s.startsWith("AR"):
2345
+ resultObject.forceAR = parseFloat(s.replace("AR", ""));
2346
+ break;
2347
+ case s.startsWith("OD"):
2348
+ resultObject.forceOD = parseFloat(s.replace("OD", ""));
2349
+ break;
2350
+ case s.startsWith("HP"):
2351
+ resultObject.forceHP = parseFloat(s.replace("HP", ""));
2352
+ break;
2353
+ // FL follow delay
2354
+ case s.startsWith("FLD"):
2355
+ resultObject.flashlightFollowDelay =
2356
+ parseFloat(s.replace("FLD", "")) || 0.12;
2357
+ break;
2358
+ // Speed multiplier
2359
+ case s.startsWith("x"):
2360
+ resultObject.speedMultiplier =
2361
+ parseFloat(s.replace("x", "")) || 1;
2362
+ break;
2363
+ }
2042
2364
  }
2043
2365
  }
2044
2366
  let bufferIndex;
@@ -2111,7 +2433,7 @@ class ReplayAnalyzer {
2111
2433
  const replayObjectData = {
2112
2434
  accuracy: 0,
2113
2435
  tickset: [],
2114
- result: 0,
2436
+ result: exports.HitResult.miss,
2115
2437
  };
2116
2438
  replayObjectData.accuracy =
2117
2439
  replayDataBuffer.readInt16BE(bufferCounter);
@@ -2139,41 +2461,41 @@ class ReplayAnalyzer {
2139
2461
  resultObject.hitObjectData.push(replayObjectData);
2140
2462
  }
2141
2463
  // Parse max combo, hit results, and accuracy in old replay version
2142
- if (resultObject.replayVersion < 3 && this.beatmap) {
2143
- let hit300 = 0;
2144
- let hit300k = 0;
2145
- let hit100 = 0;
2146
- let hit100k = 0;
2147
- let hit50 = 0;
2148
- let hit0 = 0;
2149
- let grantsGekiOrKatu = true;
2150
- const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2464
+ if (resultObject.replayVersion < 3) {
2465
+ const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2151
2466
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2152
2467
  ? this.beatmap.beatmap
2153
- : this.beatmap).hitObjects.objects;
2468
+ : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2469
+ let grantsGekiOrKatu = true;
2154
2470
  for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2155
2471
  // Hit result
2156
2472
  const hitObjectData = resultObject.hitObjectData[i];
2157
- const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
2473
+ const isNextNewCombo = objects
2474
+ ? i + 1 !== objects.length
2475
+ ? objects[i + 1].isNewCombo
2476
+ : true
2477
+ : false;
2158
2478
  switch (hitObjectData.result) {
2159
2479
  case exports.HitResult.miss:
2160
- ++hit0;
2480
+ ++resultObject.accuracy.nmiss;
2161
2481
  grantsGekiOrKatu = false;
2162
2482
  break;
2163
2483
  case exports.HitResult.meh:
2164
- ++hit50;
2484
+ ++resultObject.accuracy.n50;
2165
2485
  grantsGekiOrKatu = false;
2166
2486
  break;
2167
2487
  case exports.HitResult.good:
2168
- ++hit100;
2488
+ ++resultObject.accuracy.n100;
2169
2489
  if (grantsGekiOrKatu && isNextNewCombo) {
2170
- ++hit100k;
2490
+ (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2491
+ ++resultObject.hit100k;
2171
2492
  }
2172
2493
  break;
2173
2494
  case exports.HitResult.great:
2174
- ++hit300;
2495
+ ++resultObject.accuracy.n300;
2175
2496
  if (grantsGekiOrKatu && isNextNewCombo) {
2176
- ++hit300k;
2497
+ (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2498
+ ++resultObject.hit300k;
2177
2499
  }
2178
2500
  break;
2179
2501
  }
@@ -2181,55 +2503,7 @@ class ReplayAnalyzer {
2181
2503
  grantsGekiOrKatu = true;
2182
2504
  }
2183
2505
  }
2184
- resultObject.hit300k = hit300k;
2185
- resultObject.hit100k = hit100k;
2186
- resultObject.accuracy = new osuBase.Accuracy({
2187
- n300: hit300,
2188
- n100: hit100,
2189
- n50: hit50,
2190
- nmiss: hit0,
2191
- nobjects: hit300 + hit100 + hit50 + hit0,
2192
- });
2193
- // Determine rank
2194
- const totalHits = resultObject.accuracy.n300 +
2195
- resultObject.accuracy.n100 +
2196
- resultObject.accuracy.n50 +
2197
- resultObject.accuracy.nmiss;
2198
- const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
2199
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2200
- switch (true) {
2201
- case resultObject.accuracy.value() === 1:
2202
- if (isHidden) {
2203
- resultObject.rank = "XH";
2204
- }
2205
- else {
2206
- resultObject.rank = "X";
2207
- }
2208
- break;
2209
- case hit300Ratio > 0.9 &&
2210
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2211
- !resultObject.accuracy.nmiss:
2212
- if (isHidden) {
2213
- resultObject.rank = "SH";
2214
- }
2215
- else {
2216
- resultObject.rank = "S";
2217
- }
2218
- break;
2219
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2220
- hit300Ratio > 0.9:
2221
- resultObject.rank = "A";
2222
- break;
2223
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2224
- hit300Ratio > 0.8:
2225
- resultObject.rank = "B";
2226
- break;
2227
- case hit300Ratio > 0.6:
2228
- resultObject.rank = "C";
2229
- break;
2230
- default:
2231
- resultObject.rank = "D";
2232
- }
2506
+ determineRank();
2233
2507
  }
2234
2508
  this.data = new ReplayData(resultObject);
2235
2509
  }
@@ -2251,12 +2525,12 @@ class ReplayAnalyzer {
2251
2525
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2252
2526
  ? this.beatmap.beatmap
2253
2527
  : this.beatmap;
2254
- const objects = beatmap.hitObjects.objects;
2255
- const stats = new osuBase.MapStats({
2256
- od: beatmap.difficulty.od,
2257
- mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2258
- }).calculate();
2259
- const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2528
+ const { objects } = beatmap.hitObjects;
2529
+ const od = osuBase.calculateDroidDifficultyStatistics({
2530
+ overallDifficulty: beatmap.difficulty.od,
2531
+ mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
2532
+ }).overallDifficulty;
2533
+ const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2260
2534
  // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2261
2535
  // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2262
2536
  //
@@ -2270,8 +2544,8 @@ class ReplayAnalyzer {
2270
2544
  accuracies.push(0);
2271
2545
  continue;
2272
2546
  }
2273
- const accuracy = v.accuracy;
2274
- if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2547
+ const { accuracy } = v;
2548
+ if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2275
2549
  accuracies.push(0);
2276
2550
  continue;
2277
2551
  }
@@ -2310,13 +2584,11 @@ class ReplayAnalyzer {
2310
2584
  MOD_PERFECT: "f",
2311
2585
  MOD_SUDDENDEATH: "u",
2312
2586
  MOD_SCOREV2: "v",
2587
+ MOD_FLASHLIGHT: "i",
2313
2588
  };
2314
2589
  let modString = "";
2315
2590
  for (const mod of replayMods) {
2316
2591
  for (const property in replayModsConstants) {
2317
- if (!(property in replayModsConstants)) {
2318
- continue;
2319
- }
2320
2592
  if (!mod.includes(property)) {
2321
2593
  continue;
2322
2594
  }
@@ -2333,12 +2605,36 @@ class ReplayAnalyzer {
2333
2605
  * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2334
2606
  */
2335
2607
  checkFor3Finger() {
2608
+ var _a;
2336
2609
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2337
2610
  return;
2338
2611
  }
2339
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap instanceof osuBase.Beatmap
2340
- ? this.beatmap
2341
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2612
+ if (!this.convertedBeatmap) {
2613
+ const mods = this.data.convertedMods.slice();
2614
+ if ([
2615
+ this.data.forceCS,
2616
+ this.data.forceAR,
2617
+ this.data.forceOD,
2618
+ this.data.forceHP,
2619
+ ].some((v) => v !== undefined)) {
2620
+ mods.push(new osuBase.ModDifficultyAdjust({
2621
+ cs: this.data.forceCS,
2622
+ ar: this.data.forceAR,
2623
+ od: this.data.forceOD,
2624
+ hp: this.data.forceHP,
2625
+ }));
2626
+ }
2627
+ (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2628
+ ? this.beatmap
2629
+ : this.beatmap.beatmap).convert({
2630
+ mode: osuBase.Modes.droid,
2631
+ mods: mods,
2632
+ customSpeedMultiplier: this.data.speedMultiplier,
2633
+ }));
2634
+ }
2635
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2636
+ ? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
2637
+ : new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2342
2638
  const result = threeFingerChecker.check();
2343
2639
  this.is3Finger = result.is3Finger;
2344
2640
  this.tapPenalty = result.penalty;
@@ -2367,12 +2663,34 @@ class ReplayAnalyzer {
2367
2663
  * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2368
2664
  */
2369
2665
  checkForSliderCheesing() {
2666
+ var _a;
2370
2667
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2371
2668
  return;
2372
2669
  }
2373
- const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
2374
- ? this.beatmap
2375
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2670
+ if (!this.convertedBeatmap) {
2671
+ const mods = this.data.convertedMods.slice();
2672
+ if ([
2673
+ this.data.forceCS,
2674
+ this.data.forceAR,
2675
+ this.data.forceOD,
2676
+ this.data.forceHP,
2677
+ ].some((v) => v !== undefined)) {
2678
+ mods.push(new osuBase.ModDifficultyAdjust({
2679
+ cs: this.data.forceCS,
2680
+ ar: this.data.forceAR,
2681
+ od: this.data.forceOD,
2682
+ hp: this.data.forceHP,
2683
+ }));
2684
+ }
2685
+ (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2686
+ ? this.beatmap
2687
+ : this.beatmap.beatmap).convert({
2688
+ mode: osuBase.Modes.droid,
2689
+ mods: mods,
2690
+ customSpeedMultiplier: this.data.speedMultiplier,
2691
+ }));
2692
+ }
2693
+ const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2376
2694
  this.sliderCheesePenalty = sliderCheeseChecker.check();
2377
2695
  this.hasBeenCheckedForSliderCheesing = true;
2378
2696
  }
@@ -2386,33 +2704,6 @@ class ReplayAnalyzer {
2386
2704
  * This is used when analyzing replays using replay analyzer.
2387
2705
  */
2388
2706
  class ReplayObjectData {
2389
- /**
2390
- * For circles, this is the offset at which the circle was hit. If the hit accuracy is 10000, it means the circle was tapped too late ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/HitCircle.java#L298-L306)).
2391
- *
2392
- * For sliders, this is the offset at which the slider head was hit. For
2393
- * sliderbreaks, the accuracy would be `Math.floor(<hit window 50>ms) + 13ms` ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821)).
2394
- *
2395
- * For spinners, this is the total amount at which the spinner was spinned:
2396
- * ```js
2397
- * const rotations = Math.floor(data.accuracy / 4);
2398
- * ```
2399
- * The remainder of the division denotes the hit result of the spinner:
2400
- * - `HitResult.great`: 3
2401
- * - `HitResult.good`: 2
2402
- * - `HitResult.meh`: 1
2403
- * - `HitResult.miss`: 0
2404
- */
2405
- accuracy;
2406
- /**
2407
- * The tickset of the hitobject.
2408
- *
2409
- * This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
2410
- */
2411
- tickset;
2412
- /**
2413
- * The bitwise hit result of the hitobject.
2414
- */
2415
- result;
2416
2707
  constructor(values) {
2417
2708
  this.accuracy = values.accuracy;
2418
2709
  this.tickset = values.tickset;
@@ -2423,6 +2714,7 @@ class ReplayObjectData {
2423
2714
  exports.CursorData = CursorData;
2424
2715
  exports.CursorOccurrence = CursorOccurrence;
2425
2716
  exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
2717
+ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2426
2718
  exports.ReplayAnalyzer = ReplayAnalyzer;
2427
2719
  exports.ReplayData = ReplayData;
2428
2720
  exports.ReplayObjectData = ReplayObjectData;