@rian8337/osu-droid-replay-analyzer 4.0.0-beta.15 → 4.0.0-beta.17

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
@@ -312,6 +312,574 @@ exports.HitResult = void 0;
312
312
  HitResult[HitResult["great"] = 4] = "great";
313
313
  })(exports.HitResult || (exports.HitResult = {}));
314
314
 
315
+ /**
316
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
317
+ */
318
+ class RebalanceThreeFingerChecker {
319
+ /**
320
+ * The beatmap that is being analyzed.
321
+ */
322
+ beatmap;
323
+ /**
324
+ * The data of the replay.
325
+ */
326
+ data;
327
+ /**
328
+ * The difficulty attributes of the beatmap.
329
+ */
330
+ difficultyAttributes;
331
+ /**
332
+ * The hitobjects to be analyzed.
333
+ *
334
+ * This is being maintained separately due to possible change in object scale.
335
+ */
336
+ hitObjects;
337
+ /**
338
+ * The distance threshold between cursors to assume that two cursors are
339
+ * actually pressed with 1 finger in osu!pixels.
340
+ *
341
+ * This is used to prevent cases where a player would lift their finger
342
+ * too fast to the point where the 4th cursor instance or beyond is recorded
343
+ * as 1st, 2nd, or 3rd cursor instance.
344
+ */
345
+ cursorDistancingDistanceThreshold = 60;
346
+ /**
347
+ * The threshold for the amount of cursors that are assumed to be pressed
348
+ * by a single finger.
349
+ */
350
+ cursorDistancingCountThreshold = 10;
351
+ /**
352
+ * The threshold for the time difference of cursors that are assumed to be pressed
353
+ * by a single finger, in milliseconds.
354
+ */
355
+ cursorDistancingTimeThreshold = 1000;
356
+ /**
357
+ * The amount of notes that has a tap strain exceeding `strainThreshold`.
358
+ */
359
+ strainNoteCount;
360
+ /**
361
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
362
+ *
363
+ * Increasing this number will increase detection accuracy, however
364
+ * it also increases the chance of falsely flagged plays.
365
+ */
366
+ threeFingerRatioThreshold = 0.01;
367
+ /**
368
+ * Extended sections of the beatmap for drag detection.
369
+ */
370
+ beatmapSections = [];
371
+ /**
372
+ * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
373
+ */
374
+ hitWindow;
375
+ /**
376
+ * A reprocessed break points to match right on object time.
377
+ *
378
+ * This is used to increase detection accuracy since break points do not start right at the
379
+ * start of the hitobject before it and do not end right at the first hitobject after it.
380
+ */
381
+ breakPointAccurateTimes = [];
382
+ /**
383
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
384
+ *
385
+ * Each index represents the cursor index.
386
+ */
387
+ downCursorInstances = [];
388
+ /**
389
+ * Nerf factors from all sections that were three-fingered.
390
+ */
391
+ nerfFactors = [];
392
+ /**
393
+ * Whether this score uses the Precise mod.
394
+ */
395
+ isPrecise;
396
+ /**
397
+ * @param beatmap The beatmap to analyze.
398
+ * @param data The data of the replay.
399
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
400
+ */
401
+ constructor(beatmap, data, difficultyAttributes) {
402
+ this.beatmap = beatmap;
403
+ this.data = data;
404
+ this.difficultyAttributes = difficultyAttributes;
405
+ const od = osuBase.calculateDroidDifficultyStatistics({
406
+ overallDifficulty: beatmap.difficulty.od,
407
+ mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
408
+ !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
409
+ convertOverallDifficulty: false,
410
+ }).overallDifficulty;
411
+ this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
412
+ this.hitWindow = new osuBase.DroidHitWindow(od);
413
+ this.strainNoteCount =
414
+ this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
415
+ this.hitObjects = beatmap.hitObjects.objects;
416
+ }
417
+ /**
418
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
419
+ *
420
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
421
+ */
422
+ static isEligibleToDetect(difficultyAttributes) {
423
+ return difficultyAttributes.possibleThreeFingeredSections.length > 0;
424
+ }
425
+ /**
426
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
427
+ *
428
+ * The beatmap will be separated into sections and each section will be determined
429
+ * whether or not it is dragged.
430
+ *
431
+ * After that, each section will be assigned a nerf factor based on whether or not
432
+ * the section is 3-fingered. These nerf factors will be summed up into a final
433
+ * nerf factor, taking beatmap difficulty into account.
434
+ */
435
+ check() {
436
+ if (this.strainNoteCount === 0) {
437
+ return { is3Finger: false, penalty: 1 };
438
+ }
439
+ this.getAccurateBreakPoints();
440
+ this.filterCursorInstances();
441
+ this.getBeatmapSections();
442
+ this.calculateNerfFactors();
443
+ const finalPenalty = this.calculateFinalPenalty();
444
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
445
+ }
446
+ /**
447
+ * Generates a new set of "accurate break points".
448
+ *
449
+ * This is done to increase detection accuracy since break points do not start right at the
450
+ * end of the hitobject before it and do not end right at the first hitobject after it.
451
+ */
452
+ getAccurateBreakPoints() {
453
+ const objectData = this.data.hitObjectData;
454
+ for (const breakPoint of this.beatmap.events.breaks) {
455
+ const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
456
+ const objectBefore = this.hitObjects[beforeIndex];
457
+ const objectBeforeData = objectData[beforeIndex];
458
+ let timeBefore = objectBefore.endTime;
459
+ if (objectBefore instanceof osuBase.Circle) {
460
+ if (objectBeforeData.result !== exports.HitResult.miss) {
461
+ timeBefore += objectBeforeData.accuracy;
462
+ }
463
+ else {
464
+ timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
465
+ }
466
+ }
467
+ const afterIndex = beforeIndex + 1;
468
+ const objectAfter = this.hitObjects[afterIndex];
469
+ const objectAfterData = objectData[afterIndex];
470
+ let timeAfter = this.hitObjects[afterIndex].startTime;
471
+ if (objectAfter instanceof osuBase.Circle &&
472
+ objectAfterData.result !== exports.HitResult.miss) {
473
+ timeAfter += objectAfterData.accuracy;
474
+ }
475
+ this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
476
+ startTime: timeBefore,
477
+ endTime: timeAfter,
478
+ }));
479
+ }
480
+ }
481
+ /**
482
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
483
+ *
484
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
485
+ */
486
+ filterCursorInstances() {
487
+ const objectData = this.data.hitObjectData;
488
+ const firstObjectResult = objectData[0].result;
489
+ const lastObjectResult = objectData.at(-1).result;
490
+ const firstObject = this.hitObjects[0];
491
+ const lastObject = this.hitObjects.at(-1);
492
+ // For sliders, automatically set hit window length to be as lenient as possible.
493
+ let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
494
+ if (firstObject instanceof osuBase.Circle) {
495
+ switch (firstObjectResult) {
496
+ case exports.HitResult.great:
497
+ firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
498
+ break;
499
+ case exports.HitResult.good:
500
+ firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
501
+ break;
502
+ default:
503
+ firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
504
+ }
505
+ }
506
+ // For sliders, automatically set hit window length to be as lenient as possible.
507
+ let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
508
+ if (lastObject instanceof osuBase.Circle) {
509
+ switch (lastObjectResult) {
510
+ case exports.HitResult.great:
511
+ lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
512
+ break;
513
+ case exports.HitResult.good:
514
+ lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
515
+ break;
516
+ default:
517
+ lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
518
+ }
519
+ }
520
+ else if (lastObject instanceof osuBase.Slider) {
521
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
522
+ }
523
+ // These hit time uses hit window length as threshold.
524
+ // This is because cursors aren't recorded exactly at hit time,
525
+ // probably due to the game's behavior.
526
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
527
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
528
+ for (const cursorInstance of this.data.cursorMovement) {
529
+ const validOccurrences = [];
530
+ for (const group of cursorInstance.occurrenceGroups) {
531
+ if (group.startTime < firstObjectHitTime) {
532
+ continue;
533
+ }
534
+ if (group.startTime > lastObjectHitTime) {
535
+ break;
536
+ }
537
+ if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
538
+ group.endTime <= v.endTime)) {
539
+ continue;
540
+ }
541
+ validOccurrences.push(group.down);
542
+ }
543
+ this.downCursorInstances.push(validOccurrences);
544
+ }
545
+ }
546
+ /**
547
+ * Divides the beatmap into sections, which will be used to
548
+ * detect dragged sections and improve detection speed.
549
+ */
550
+ getBeatmapSections() {
551
+ const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
552
+ for (const section of this.difficultyAttributes
553
+ .possibleThreeFingeredSections) {
554
+ const dragFingerIndex = this.findDragIndex(section);
555
+ const objects = [];
556
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
557
+ objects.push({
558
+ object: this.hitObjects[i],
559
+ ...this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex),
560
+ });
561
+ }
562
+ this.beatmapSections.push({
563
+ ...section,
564
+ dragFingerIndex: dragFingerIndex,
565
+ objects: objects,
566
+ });
567
+ }
568
+ }
569
+ /**
570
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
571
+ *
572
+ * @param object The object to obtain the index for.
573
+ * @param objectData The hit data of the object.
574
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
575
+ * @param excludedIndices The cursor indices that should not be checked.
576
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
577
+ */
578
+ getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
579
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
580
+ return {
581
+ pressingCursorInstanceIndex: -1,
582
+ pressingCursorIndex: -1,
583
+ };
584
+ }
585
+ const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
586
+ // Check for sliderbreaks and treat them as misses.
587
+ if (object instanceof osuBase.Slider &&
588
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
589
+ return {
590
+ pressingCursorInstanceIndex: -1,
591
+ pressingCursorIndex: -1,
592
+ };
593
+ }
594
+ // We are not directly using hit time to determine which cursor pressed the object
595
+ // to account for time difference between hit registration and object judgement.
596
+ let minHitTime = object.startTime;
597
+ let maxHitTime = object.startTime;
598
+ if (object instanceof osuBase.Circle) {
599
+ let hitWindowGap = hitWindow50;
600
+ switch (objectData.result) {
601
+ case exports.HitResult.great:
602
+ hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
603
+ break;
604
+ case exports.HitResult.good:
605
+ hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
606
+ break;
607
+ }
608
+ minHitTime -= hitWindowGap;
609
+ maxHitTime += hitWindowGap;
610
+ }
611
+ else if (object instanceof osuBase.Slider) {
612
+ minHitTime -= hitWindow50;
613
+ maxHitTime += Math.min(hitWindow50, object.spanDuration);
614
+ }
615
+ const hitTime = object.startTime + objectData.accuracy;
616
+ let nearestCursorInstanceIndex = null;
617
+ let nearestCursorIndex = null;
618
+ let nearestTime = Number.POSITIVE_INFINITY;
619
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
620
+ if (excludedCursorIndices.includes(i)) {
621
+ continue;
622
+ }
623
+ const cursors = this.downCursorInstances[i];
624
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
625
+ const cursor = cursors[j];
626
+ if (cursor.time < minHitTime) {
627
+ continue;
628
+ }
629
+ if (cursor.time > maxHitTime) {
630
+ break;
631
+ }
632
+ const deltaTime = Math.abs(hitTime - cursor.time);
633
+ if (deltaTime > nearestTime) {
634
+ break;
635
+ }
636
+ nearestCursorInstanceIndex = i;
637
+ nearestCursorIndex = j;
638
+ nearestTime = deltaTime;
639
+ }
640
+ }
641
+ return {
642
+ pressingCursorInstanceIndex: nearestCursorInstanceIndex ?? -1,
643
+ pressingCursorIndex: nearestCursorIndex ?? -1,
644
+ };
645
+ }
646
+ /**
647
+ * Checks if a section is dragged and returns the index of the drag finger.
648
+ *
649
+ * If the section is not dragged, -1 will be returned.
650
+ *
651
+ * @param section The section to check.
652
+ */
653
+ findDragIndex(section) {
654
+ const objectData = this.data.hitObjectData;
655
+ const firstObject = this.hitObjects[section.firstObjectIndex];
656
+ const lastObject = this.hitObjects[section.lastObjectIndex];
657
+ let firstObjectMinHitTime = firstObject.startTime;
658
+ if (firstObject instanceof osuBase.Circle) {
659
+ switch (objectData[section.firstObjectIndex].result) {
660
+ case exports.HitResult.great:
661
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
662
+ break;
663
+ case exports.HitResult.good:
664
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
665
+ break;
666
+ default:
667
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
668
+ }
669
+ }
670
+ else if (firstObject instanceof osuBase.Slider) {
671
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
672
+ }
673
+ let lastObjectMaxHitTime = lastObject.startTime;
674
+ if (lastObject instanceof osuBase.Circle) {
675
+ switch (objectData[section.lastObjectIndex].result) {
676
+ case exports.HitResult.great:
677
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
678
+ break;
679
+ case exports.HitResult.good:
680
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
681
+ break;
682
+ default:
683
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
684
+ }
685
+ }
686
+ else if (lastObject instanceof osuBase.Slider) {
687
+ lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
688
+ }
689
+ // Since there may be more than 1 cursor instance index,
690
+ // we check which cursor instance follows hitobjects all over.
691
+ const cursorIndices = [];
692
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
693
+ const c = this.data.cursorMovement[i];
694
+ if (c.occurrenceGroups.length === 0) {
695
+ continue;
696
+ }
697
+ // Do not include cursors that don't have an occurence in this section
698
+ // this speeds up checking process.
699
+ if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
700
+ v.endTime <= lastObjectMaxHitTime).length === 0) {
701
+ continue;
702
+ }
703
+ // If this cursor instance doesn't move, it's not the cursor instance we want.
704
+ if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
705
+ 0) {
706
+ continue;
707
+ }
708
+ cursorIndices.push(i);
709
+ }
710
+ const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
711
+ const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
712
+ const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
713
+ for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
714
+ const object = sectionObjects[i];
715
+ const objectData = sectionReplayObjectData[i];
716
+ if (object instanceof osuBase.Spinner ||
717
+ objectData.result === exports.HitResult.miss) {
718
+ continue;
719
+ }
720
+ // Exclude sliderbreaks.
721
+ if (object instanceof osuBase.Slider &&
722
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
723
+ continue;
724
+ }
725
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
726
+ const hitTime = object.startTime + objectData.accuracy;
727
+ // Observe the cursor position at the object's hit time.
728
+ for (let j = 0; j < cursorIndices.length; ++j) {
729
+ if (cursorIndices[j] === -1) {
730
+ continue;
731
+ }
732
+ const cursorData = this.data.cursorMovement[cursorIndices[j]];
733
+ const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
734
+ if (!cursorGroup) {
735
+ continue;
736
+ }
737
+ const cursors = cursorGroup.allOccurrences;
738
+ let isInObject = false;
739
+ for (let k = 1; k < cursors.length; ++k) {
740
+ const cursor = cursors[k];
741
+ const prevCursor = cursors[k - 1];
742
+ // Only consider cursor at interval prev.time <= hitTime <= current.time.
743
+ if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
744
+ switch (cursor.id) {
745
+ case exports.MovementType.up:
746
+ isInObject =
747
+ prevCursor.position.getDistance(objectPosition) <= object.radius;
748
+ break;
749
+ case exports.MovementType.move: {
750
+ // Interpolate movement.
751
+ const t = (hitTime - prevCursor.time) /
752
+ (cursor.time - prevCursor.time);
753
+ 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));
754
+ isInObject =
755
+ objectPosition.getDistance(cursorPosition) <= object.radius;
756
+ }
757
+ }
758
+ break;
759
+ }
760
+ }
761
+ if (!isInObject) {
762
+ cursorIndices[j] = -1;
763
+ }
764
+ }
765
+ }
766
+ return cursorIndices.find((v) => v !== -1) ?? -1;
767
+ }
768
+ /**
769
+ * Creates nerf factors by scanning through objects.
770
+ */
771
+ calculateNerfFactors() {
772
+ for (const beatmapSection of this.beatmapSections) {
773
+ const cursorAmounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
774
+ const cursorVectorTimes = [];
775
+ for (const object of beatmapSection.objects) {
776
+ if (object.pressingCursorIndex === -1 ||
777
+ object.pressingCursorInstanceIndex ===
778
+ beatmapSection.dragFingerIndex) {
779
+ continue;
780
+ }
781
+ ++cursorAmounts[object.pressingCursorInstanceIndex];
782
+ const cursor = this.downCursorInstances[object.pressingCursorInstanceIndex][object.pressingCursorIndex];
783
+ cursorVectorTimes.push({
784
+ vector: new osuBase.Vector2(cursor.position.x, cursor.position.y),
785
+ time: cursor.time,
786
+ });
787
+ }
788
+ if (beatmapSection.dragFingerIndex !== -1) {
789
+ // Remove the drag index to prevent it from being picked up into the detection.
790
+ cursorAmounts.splice(beatmapSection.dragFingerIndex, 1);
791
+ }
792
+ // This index will be used to detect if a section is 3-fingered.
793
+ // If the section is dragged, the dragged instance will be ignored,
794
+ // hence why the index is 1 less than nondragged section.
795
+ const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
796
+ // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
797
+ // to check if the section is 3-fingered.
798
+ const threeFingerRatio = cursorAmounts
799
+ .slice(fingerSplitIndex)
800
+ .reduce((acc, value) => acc + value, 0) /
801
+ Math.max(cursorAmounts
802
+ .slice(0, fingerSplitIndex)
803
+ .reduce((acc, value) => acc + value, 0), 1);
804
+ const similarPresses = [];
805
+ cursorVectorTimes.sort((a, b) => a.time - b.time);
806
+ for (const cursorVectorTime of cursorVectorTimes) {
807
+ const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
808
+ this.cursorDistancingDistanceThreshold);
809
+ if (pressIndex !== -1) {
810
+ if (cursorVectorTime.time -
811
+ similarPresses[pressIndex].lastTime >=
812
+ this.cursorDistancingTimeThreshold) {
813
+ // If the previous press is too late, remove it from the
814
+ // list and register the current press as a new press.
815
+ similarPresses.splice(pressIndex, 1);
816
+ similarPresses.push({
817
+ vector: cursorVectorTime.vector,
818
+ count: 1,
819
+ lastTime: cursorVectorTime.time,
820
+ });
821
+ continue;
822
+ }
823
+ similarPresses[pressIndex].vector = cursorVectorTime.vector;
824
+ similarPresses[pressIndex].lastTime = cursorVectorTime.time;
825
+ ++similarPresses[pressIndex].count;
826
+ }
827
+ else {
828
+ similarPresses.push({
829
+ vector: cursorVectorTime.vector,
830
+ count: 1,
831
+ lastTime: cursorVectorTime.time,
832
+ });
833
+ }
834
+ }
835
+ // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
836
+ const threeFingerPresses = similarPresses
837
+ .filter((v) => v.count >= this.cursorDistancingCountThreshold)
838
+ .sort((a, b) => b.count - a.count)
839
+ .slice(fingerSplitIndex);
840
+ if (threeFingerRatio > this.threeFingerRatioThreshold ||
841
+ threeFingerPresses.length > 0) {
842
+ const objectCount = beatmapSection.lastObjectIndex -
843
+ beatmapSection.firstObjectIndex +
844
+ 1;
845
+ // We can ignore the first 3 (2 for drag) filled cursor instances
846
+ // since they are guaranteed not 3 finger.
847
+ const threeFingerCursorAmounts = cursorAmounts
848
+ .slice(fingerSplitIndex)
849
+ .filter((amount) => amount > 0);
850
+ // Finger factor applies more penalty if more fingers were used.
851
+ const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
852
+ ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
853
+ Math.pow(((index + 1) * value * objectCount) /
854
+ this.strainNoteCount, 0.9), 1)
855
+ : Math.pow(threeFingerPresses.reduce((acc, value, index) => acc +
856
+ Math.pow(((index + 1) *
857
+ (value.count /
858
+ (this
859
+ .cursorDistancingCountThreshold *
860
+ 2)) *
861
+ objectCount) /
862
+ this.strainNoteCount, 0.2), 1), 0.2);
863
+ // Length factor applies more penalty if there are more 3-fingered object.
864
+ const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
865
+ this.nerfFactors.push({
866
+ strainFactor: Math.max(1, beatmapSection.sumStrain),
867
+ fingerFactor,
868
+ lengthFactor,
869
+ });
870
+ }
871
+ }
872
+ }
873
+ /**
874
+ * Calculates the final penalty.
875
+ */
876
+ calculateFinalPenalty() {
877
+ return this.nerfFactors.reduce((a, n) => a +
878
+ 0.015 *
879
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
880
+ }
881
+ }
882
+
315
883
  /**
316
884
  * Represents a replay data in an osu!droid replay.
317
885
  *
@@ -386,10 +954,6 @@ class ThreeFingerChecker {
386
954
  * The difficulty attributes of the beatmap.
387
955
  */
388
956
  difficultyAttributes;
389
- /**
390
- * The true scale of objects.
391
- */
392
- trueScale;
393
957
  /**
394
958
  * The distance threshold between cursors to assume that two cursors are
395
959
  * actually pressed with 1 finger in osu!pixels.
@@ -466,20 +1030,16 @@ class ThreeFingerChecker {
466
1030
  this.beatmap = beatmap;
467
1031
  this.data = data;
468
1032
  this.difficultyAttributes = difficultyAttributes;
469
- const stats = new osuBase.MapStats({
470
- od: this.beatmap.difficulty.od,
1033
+ const od = osuBase.calculateDroidDifficultyStatistics({
1034
+ overallDifficulty: beatmap.difficulty.od,
471
1035
  mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
472
1036
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
473
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1037
+ convertOverallDifficulty: false,
1038
+ }).overallDifficulty;
474
1039
  this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
475
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1040
+ this.hitWindow = new osuBase.DroidHitWindow(od);
476
1041
  this.strainNoteCount =
477
1042
  this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
478
- const circleSize = new osuBase.MapStats({
479
- cs: this.beatmap.difficulty.cs,
480
- mods: this.difficultyAttributes.mods,
481
- }).calculate({ mode: osuBase.Modes.droid }).cs;
482
- this.trueScale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
483
1043
  }
484
1044
  /**
485
1045
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -572,11 +1132,13 @@ class ThreeFingerChecker {
572
1132
  filterCursorInstances() {
573
1133
  const { objects } = this.beatmap.hitObjects;
574
1134
  const objectData = this.data.hitObjectData;
1135
+ const firstObject = objects[0];
1136
+ const lastObject = objects.at(-1);
575
1137
  const firstObjectResult = objectData[0].result;
576
1138
  const lastObjectResult = objectData.at(-1).result;
577
1139
  // For sliders, automatically set hit window length to be as lenient as possible.
578
1140
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
579
- if (objects[0] instanceof osuBase.Circle) {
1141
+ if (firstObject instanceof osuBase.Circle) {
580
1142
  switch (firstObjectResult) {
581
1143
  case exports.HitResult.great:
582
1144
  firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -590,7 +1152,7 @@ class ThreeFingerChecker {
590
1152
  }
591
1153
  // For sliders, automatically set hit window length to be as lenient as possible.
592
1154
  let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
593
- if (objects.at(-1) instanceof osuBase.Circle) {
1155
+ if (lastObject instanceof osuBase.Circle) {
594
1156
  switch (lastObjectResult) {
595
1157
  case exports.HitResult.great:
596
1158
  lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -605,8 +1167,8 @@ class ThreeFingerChecker {
605
1167
  // These hit time uses hit window length as threshold.
606
1168
  // This is because cursors aren't recorded exactly at hit time,
607
1169
  // probably due to the game's behavior.
608
- const firstObjectHitTime = objects[0].startTime - firstObjectHitWindow;
609
- const lastObjectHitTime = objects.at(-1).startTime + lastObjectHitWindow;
1170
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
1171
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
610
1172
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
611
1173
  const cursorInstance = this.data.cursorMovement[i];
612
1174
  const validOccurrences = [];
@@ -728,7 +1290,7 @@ class ThreeFingerChecker {
728
1290
  findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
729
1291
  const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
730
1292
  for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
731
- let object = sectionObjects[i];
1293
+ const object = sectionObjects[i];
732
1294
  const objectData = sectionReplayObjectData[i];
733
1295
  if (object instanceof osuBase.Spinner ||
734
1296
  objectData.result === exports.HitResult.miss) {
@@ -739,11 +1301,6 @@ class ThreeFingerChecker {
739
1301
  objectData.accuracy === Math.floor(hitWindow50) + 13) {
740
1302
  continue;
741
1303
  }
742
- if (object.droidScale !== this.trueScale) {
743
- // Deep copy the instance so that we can assign scale.
744
- object = osuBase.Utils.deepCopy(object);
745
- object.droidScale = this.trueScale;
746
- }
747
1304
  const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
748
1305
  const hitTime = object.startTime + objectData.accuracy;
749
1306
  // Observe the cursor position at the object's hit time.
@@ -770,7 +1327,7 @@ class ThreeFingerChecker {
770
1327
  switch (cursor.id) {
771
1328
  case exports.MovementType.up:
772
1329
  isInObject =
773
- prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
1330
+ prevCursor.position.getDistance(objectPosition) <= object.radius;
774
1331
  break;
775
1332
  case exports.MovementType.move:
776
1333
  // Interpolate movement.
@@ -781,7 +1338,7 @@ class ThreeFingerChecker {
781
1338
  (cursor.time - prevCursor.time);
782
1339
  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));
783
1340
  isInObject =
784
- objectPosition.getDistance(cursorPosition) <= object.getRadius(osuBase.Modes.droid);
1341
+ objectPosition.getDistance(cursorPosition) <= object.radius;
785
1342
  }
786
1343
  }
787
1344
  if (!isInObject) {
@@ -952,10 +1509,9 @@ class ThreeFingerChecker {
952
1509
  * Calculates the final penalty.
953
1510
  */
954
1511
  calculateFinalPenalty() {
955
- return (1 +
956
- this.nerfFactors.reduce((a, n) => a +
957
- 0.015 *
958
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
1512
+ return this.nerfFactors.reduce((a, n) => a +
1513
+ 0.015 *
1514
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
959
1515
  }
960
1516
  }
961
1517
 
@@ -1057,12 +1613,13 @@ class TwoHandChecker {
1057
1613
  constructor(calculator, data) {
1058
1614
  this.calculator = calculator;
1059
1615
  this.data = data;
1060
- const stats = new osuBase.MapStats({
1061
- od: this.calculator.beatmap.difficulty.od,
1062
- mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
1616
+ const od = osuBase.calculateDroidDifficultyStatistics({
1617
+ overallDifficulty: calculator.beatmap.difficulty.od,
1618
+ mods: calculator.mods.filter((m) => m.isApplicableToDroid() &&
1063
1619
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1064
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1065
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1620
+ convertOverallDifficulty: false,
1621
+ }).overallDifficulty;
1622
+ this.hitWindow = new osuBase.DroidHitWindow(od);
1066
1623
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1067
1624
  // this.csvString = `Mods,${
1068
1625
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
@@ -1245,9 +1802,8 @@ class TwoHandChecker {
1245
1802
  const prevToCurrentMovement = object
1246
1803
  .getStackedPosition(osuBase.Modes.droid)
1247
1804
  .subtract(prevObjectEndPosition);
1248
- const radius = object.getRadius(osuBase.Modes.droid);
1249
1805
  // Don't consider objects that are too close to each other.
1250
- if (prevToCurrentMovement.length <= radius) {
1806
+ if (prevToCurrentMovement.length <= object.radius) {
1251
1807
  return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1252
1808
  }
1253
1809
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
@@ -1376,7 +1932,6 @@ class TwoHandChecker {
1376
1932
  cursorTime: object.startTime,
1377
1933
  };
1378
1934
  }
1379
- const radius = object.getRadius(osuBase.Modes.droid);
1380
1935
  let hitWindow = this.hitWindow50;
1381
1936
  const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1382
1937
  // For sliders, set the hit window to as lenient as possible.
@@ -1391,7 +1946,7 @@ class TwoHandChecker {
1391
1946
  }
1392
1947
  }
1393
1948
  // TODO: what to do for head sliderbreaks?
1394
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1949
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1395
1950
  let nearestCursorIndex = 0;
1396
1951
  let nearestGroupIndex = 0;
1397
1952
  let nearestCursorGroupIndex = 0;
@@ -1399,12 +1954,12 @@ class TwoHandChecker {
1399
1954
  const minimumActiveTime = object.startTime - hitWindow;
1400
1955
  const maximumActiveTime = object.startTime + hitWindow;
1401
1956
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1402
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1957
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1403
1958
  break;
1404
1959
  }
1405
1960
  const cursorData = this.data.cursorMovement[i];
1406
1961
  for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
1407
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1962
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1408
1963
  break;
1409
1964
  }
1410
1965
  const cursorGroup = cursorData.occurrenceGroups[j];
@@ -1416,7 +1971,8 @@ class TwoHandChecker {
1416
1971
  }
1417
1972
  // Validate the down press first.
1418
1973
  const { down } = cursorGroup;
1419
- if (down.position.getDistance(objectPosition) <= radius &&
1974
+ if (down.position.getDistance(objectPosition) <=
1975
+ object.radius &&
1420
1976
  Math.abs(down.time - object.startTime) <= hitWindow) {
1421
1977
  if (objectIndex > 0) {
1422
1978
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
@@ -1487,12 +2043,13 @@ class TwoHandChecker {
1487
2043
  nearestCursorGroupIndex = k;
1488
2044
  nearestCursorTime = cursorGroup.down.time;
1489
2045
  }
1490
- if (distance <= radius) {
2046
+ if (distance <= object.radius) {
1491
2047
  break;
1492
2048
  }
1493
2049
  }
1494
2050
  }
1495
- if (nearestPosition.getDistance(objectPosition) <= radius) {
2051
+ if (nearestPosition.getDistance(objectPosition) <=
2052
+ object.radius) {
1496
2053
  break;
1497
2054
  }
1498
2055
  }
@@ -1540,7 +2097,7 @@ class TwoHandChecker {
1540
2097
  else {
1541
2098
  objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
1542
2099
  }
1543
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
2100
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1544
2101
  let nearestCursorIndex = 0;
1545
2102
  let nearestGroupIndex = 0;
1546
2103
  let nearestCursorGroupIndex = 0;
@@ -1665,12 +2222,13 @@ class SliderCheeseChecker {
1665
2222
  this.beatmap = beatmap;
1666
2223
  this.data = data;
1667
2224
  this.difficultyAttributes = difficultyAttributes;
1668
- const stats = new osuBase.MapStats({
1669
- od: this.beatmap.difficulty.od,
2225
+ const od = osuBase.calculateDroidDifficultyStatistics({
2226
+ overallDifficulty: beatmap.difficulty.od,
1670
2227
  mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
1671
2228
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1672
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1673
- this.hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
2229
+ convertOverallDifficulty: false,
2230
+ }).overallDifficulty;
2231
+ this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1674
2232
  }
1675
2233
  /**
1676
2234
  * Checks if relevant sliders in the given beatmap was cheesed.
@@ -1697,12 +2255,7 @@ class SliderCheeseChecker {
1697
2255
  const cheesedDifficultyRatings = [];
1698
2256
  // Current loop indices are stored for efficiency.
1699
2257
  const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1700
- const circleSize = new osuBase.MapStats({
1701
- cs: this.beatmap.difficulty.cs,
1702
- mods: this.difficultyAttributes.mods,
1703
- }).calculate({ mode: osuBase.Modes.droid }).cs;
1704
- const scale = osuBase.CircleSizeCalculator.standardCSToStandardScale(circleSize);
1705
- const acceptableRadius = 64 * scale * 2.4;
2258
+ const acceptableRadius = objects[0].radius * 2;
1706
2259
  // Sort difficult sliders by index so that cursor loop indices work properly.
1707
2260
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
1708
2261
  .slice()
@@ -1716,12 +2269,7 @@ class SliderCheeseChecker {
1716
2269
  objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1717
2270
  continue;
1718
2271
  }
1719
- let object = objects[difficultSlider.index];
1720
- if (object.droidScale !== scale) {
1721
- // Deep clone the object so that we can assign scale properly.
1722
- object = osuBase.Utils.deepCopy(object);
1723
- object.droidScale = scale;
1724
- }
2272
+ const object = objects[difficultSlider.index];
1725
2273
  const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1726
2274
  // These time boundaries should consider the delta time between the previous and next
1727
2275
  // object as well as their hit accuracy. However, they are somewhat complicated to
@@ -1820,14 +2368,6 @@ class SliderCheeseChecker {
1820
2368
  continue;
1821
2369
  }
1822
2370
  const nestedObject = object.nestedHitObjects[i];
1823
- nestedObject.droidScale = scale;
1824
- // Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
1825
- // In that case, its time is assumed to be 36ms behind the slider's end time. However, that
1826
- // is not the case for osu!droid.
1827
- if (nestedObject instanceof osuBase.SliderTail) {
1828
- nestedObject.startTime = object.endTime;
1829
- nestedObject.endTime = object.endTime;
1830
- }
1831
2371
  const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1832
2372
  while (occurrenceLoopIndex < allOccurrences.length &&
1833
2373
  allOccurrences[occurrenceLoopIndex].time <
@@ -1944,13 +2484,14 @@ class ReplayAnalyzer {
1944
2484
  */
1945
2485
  hasBeenCheckedFor2Hand = false;
1946
2486
  /**
1947
- * Whether this repla has been checked against slider cheesing.
2487
+ * Whether this replay has been checked against slider cheesing.
1948
2488
  */
1949
2489
  hasBeenCheckedForSliderCheesing = false;
1950
2490
  /**
1951
2491
  * The amount of two-handed objects.
1952
2492
  */
1953
2493
  twoHandedNoteCount = 0;
2494
+ convertedBeatmap;
1954
2495
  // Sizes of primitive data types in Java (in bytes)
1955
2496
  BYTE_LENGTH = 1;
1956
2497
  SHORT_LENGTH = 2;
@@ -2302,12 +2843,12 @@ class ReplayAnalyzer {
2302
2843
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2303
2844
  ? this.beatmap.beatmap
2304
2845
  : this.beatmap;
2305
- const objects = beatmap.hitObjects.objects;
2306
- const stats = new osuBase.MapStats({
2307
- od: beatmap.difficulty.od,
2846
+ const { objects } = beatmap.hitObjects;
2847
+ const od = osuBase.calculateDroidDifficultyStatistics({
2848
+ overallDifficulty: beatmap.difficulty.od,
2308
2849
  mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2309
- }).calculate();
2310
- const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2850
+ }).overallDifficulty;
2851
+ const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2311
2852
  // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2312
2853
  // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2313
2854
  //
@@ -2321,8 +2862,8 @@ class ReplayAnalyzer {
2321
2862
  accuracies.push(0);
2322
2863
  continue;
2323
2864
  }
2324
- const accuracy = v.accuracy;
2325
- if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2865
+ const { accuracy } = v;
2866
+ if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2326
2867
  accuracies.push(0);
2327
2868
  continue;
2328
2869
  }
@@ -2385,9 +2926,32 @@ class ReplayAnalyzer {
2385
2926
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2386
2927
  return;
2387
2928
  }
2388
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap instanceof osuBase.Beatmap
2389
- ? this.beatmap
2390
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2929
+ if (!this.convertedBeatmap) {
2930
+ const mods = this.data.convertedMods.slice();
2931
+ if ([
2932
+ this.data.forceCS,
2933
+ this.data.forceAR,
2934
+ this.data.forceOD,
2935
+ this.data.forceHP,
2936
+ ].some((v) => v !== undefined)) {
2937
+ mods.push(new osuBase.ModDifficultyAdjust({
2938
+ cs: this.data.forceCS,
2939
+ ar: this.data.forceAR,
2940
+ od: this.data.forceOD,
2941
+ hp: this.data.forceHP,
2942
+ }));
2943
+ }
2944
+ this.convertedBeatmap ??= new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2945
+ ? this.beatmap
2946
+ : this.beatmap.beatmap).convert({
2947
+ mode: osuBase.Modes.droid,
2948
+ mods: mods,
2949
+ customSpeedMultiplier: this.data.speedMultiplier,
2950
+ });
2951
+ }
2952
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2953
+ ? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
2954
+ : new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2391
2955
  const result = threeFingerChecker.check();
2392
2956
  this.is3Finger = result.is3Finger;
2393
2957
  this.tapPenalty = result.penalty;
@@ -2419,9 +2983,30 @@ class ReplayAnalyzer {
2419
2983
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2420
2984
  return;
2421
2985
  }
2422
- const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
2423
- ? this.beatmap
2424
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2986
+ if (!this.convertedBeatmap) {
2987
+ const mods = this.data.convertedMods.slice();
2988
+ if ([
2989
+ this.data.forceCS,
2990
+ this.data.forceAR,
2991
+ this.data.forceOD,
2992
+ this.data.forceHP,
2993
+ ].some((v) => v !== undefined)) {
2994
+ mods.push(new osuBase.ModDifficultyAdjust({
2995
+ cs: this.data.forceCS,
2996
+ ar: this.data.forceAR,
2997
+ od: this.data.forceOD,
2998
+ hp: this.data.forceHP,
2999
+ }));
3000
+ }
3001
+ this.convertedBeatmap ??= new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
3002
+ ? this.beatmap
3003
+ : this.beatmap.beatmap).convert({
3004
+ mode: osuBase.Modes.droid,
3005
+ mods: mods,
3006
+ customSpeedMultiplier: this.data.speedMultiplier,
3007
+ });
3008
+ }
3009
+ const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2425
3010
  this.sliderCheesePenalty = sliderCheeseChecker.check();
2426
3011
  this.hasBeenCheckedForSliderCheesing = true;
2427
3012
  }
@@ -2472,6 +3057,7 @@ class ReplayObjectData {
2472
3057
  exports.CursorData = CursorData;
2473
3058
  exports.CursorOccurrence = CursorOccurrence;
2474
3059
  exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
3060
+ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2475
3061
  exports.ReplayAnalyzer = ReplayAnalyzer;
2476
3062
  exports.ReplayData = ReplayData;
2477
3063
  exports.ReplayObjectData = ReplayObjectData;