@rian8337/osu-droid-replay-analyzer 4.0.0-beta.14 → 4.0.0-beta.16

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
  *
@@ -337,8 +905,12 @@ class ReplayData {
337
905
  convertedMods;
338
906
  cursorMovement;
339
907
  hitObjectData;
340
- speedModification;
341
- forcedAR;
908
+ speedMultiplier;
909
+ forceCS;
910
+ forceAR;
911
+ forceOD;
912
+ forceHP;
913
+ flashlightFollowDelay;
342
914
  constructor(values) {
343
915
  this.replayVersion = values.replayVersion;
344
916
  this.folderName = values.folderName;
@@ -357,8 +929,12 @@ class ReplayData {
357
929
  this.convertedMods = values.convertedMods || [];
358
930
  this.cursorMovement = values.cursorMovement;
359
931
  this.hitObjectData = values.hitObjectData;
360
- this.speedModification = values.speedModification || 1;
361
- this.forcedAR = values.forcedAR;
932
+ this.speedMultiplier = values.speedMultiplier || 1;
933
+ this.forceCS = values.forceCS;
934
+ this.forceAR = values.forceAR;
935
+ this.forceOD = values.forceOD;
936
+ this.forceHP = values.forceHP;
937
+ this.flashlightFollowDelay = values.flashlightFollowDelay;
362
938
  }
363
939
  }
364
940
 
@@ -378,10 +954,6 @@ class ThreeFingerChecker {
378
954
  * The difficulty attributes of the beatmap.
379
955
  */
380
956
  difficultyAttributes;
381
- /**
382
- * The true scale of objects.
383
- */
384
- trueScale;
385
957
  /**
386
958
  * The distance threshold between cursors to assume that two cursors are
387
959
  * actually pressed with 1 finger in osu!pixels.
@@ -458,20 +1030,16 @@ class ThreeFingerChecker {
458
1030
  this.beatmap = beatmap;
459
1031
  this.data = data;
460
1032
  this.difficultyAttributes = difficultyAttributes;
461
- const stats = new osuBase.MapStats({
462
- od: this.beatmap.difficulty.od,
1033
+ const od = osuBase.calculateDroidDifficultyStatistics({
1034
+ overallDifficulty: beatmap.difficulty.od,
463
1035
  mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
464
1036
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
465
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1037
+ convertOverallDifficulty: false,
1038
+ }).overallDifficulty;
466
1039
  this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
467
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1040
+ this.hitWindow = new osuBase.DroidHitWindow(od);
468
1041
  this.strainNoteCount =
469
1042
  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;
475
1043
  }
476
1044
  /**
477
1045
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -564,11 +1132,13 @@ class ThreeFingerChecker {
564
1132
  filterCursorInstances() {
565
1133
  const { objects } = this.beatmap.hitObjects;
566
1134
  const objectData = this.data.hitObjectData;
1135
+ const firstObject = objects[0];
1136
+ const lastObject = objects.at(-1);
567
1137
  const firstObjectResult = objectData[0].result;
568
1138
  const lastObjectResult = objectData.at(-1).result;
569
1139
  // For sliders, automatically set hit window length to be as lenient as possible.
570
1140
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
571
- if (objects[0] instanceof osuBase.Circle) {
1141
+ if (firstObject instanceof osuBase.Circle) {
572
1142
  switch (firstObjectResult) {
573
1143
  case exports.HitResult.great:
574
1144
  firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -582,7 +1152,7 @@ class ThreeFingerChecker {
582
1152
  }
583
1153
  // For sliders, automatically set hit window length to be as lenient as possible.
584
1154
  let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
585
- if (objects.at(-1) instanceof osuBase.Circle) {
1155
+ if (lastObject instanceof osuBase.Circle) {
586
1156
  switch (lastObjectResult) {
587
1157
  case exports.HitResult.great:
588
1158
  lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
@@ -597,8 +1167,8 @@ class ThreeFingerChecker {
597
1167
  // These hit time uses hit window length as threshold.
598
1168
  // This is because cursors aren't recorded exactly at hit time,
599
1169
  // probably due to the game's behavior.
600
- const firstObjectHitTime = objects[0].startTime - firstObjectHitWindow;
601
- const lastObjectHitTime = objects.at(-1).startTime + lastObjectHitWindow;
1170
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
1171
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
602
1172
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
603
1173
  const cursorInstance = this.data.cursorMovement[i];
604
1174
  const validOccurrences = [];
@@ -720,7 +1290,7 @@ class ThreeFingerChecker {
720
1290
  findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
721
1291
  const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
722
1292
  for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
723
- let object = sectionObjects[i];
1293
+ const object = sectionObjects[i];
724
1294
  const objectData = sectionReplayObjectData[i];
725
1295
  if (object instanceof osuBase.Spinner ||
726
1296
  objectData.result === exports.HitResult.miss) {
@@ -731,11 +1301,6 @@ class ThreeFingerChecker {
731
1301
  objectData.accuracy === Math.floor(hitWindow50) + 13) {
732
1302
  continue;
733
1303
  }
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
1304
  const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
740
1305
  const hitTime = object.startTime + objectData.accuracy;
741
1306
  // Observe the cursor position at the object's hit time.
@@ -762,7 +1327,7 @@ class ThreeFingerChecker {
762
1327
  switch (cursor.id) {
763
1328
  case exports.MovementType.up:
764
1329
  isInObject =
765
- prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
1330
+ prevCursor.position.getDistance(objectPosition) <= object.radius;
766
1331
  break;
767
1332
  case exports.MovementType.move:
768
1333
  // Interpolate movement.
@@ -773,7 +1338,7 @@ class ThreeFingerChecker {
773
1338
  (cursor.time - prevCursor.time);
774
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));
775
1340
  isInObject =
776
- objectPosition.getDistance(cursorPosition) <= object.getRadius(osuBase.Modes.droid);
1341
+ objectPosition.getDistance(cursorPosition) <= object.radius;
777
1342
  }
778
1343
  }
779
1344
  if (!isInObject) {
@@ -944,10 +1509,9 @@ class ThreeFingerChecker {
944
1509
  * Calculates the final penalty.
945
1510
  */
946
1511
  calculateFinalPenalty() {
947
- return (1 +
948
- this.nerfFactors.reduce((a, n) => a +
949
- 0.015 *
950
- 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);
951
1515
  }
952
1516
  }
953
1517
 
@@ -1049,12 +1613,13 @@ class TwoHandChecker {
1049
1613
  constructor(calculator, data) {
1050
1614
  this.calculator = calculator;
1051
1615
  this.data = data;
1052
- const stats = new osuBase.MapStats({
1053
- od: this.calculator.beatmap.difficulty.od,
1054
- 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() &&
1055
1619
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1056
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1057
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1620
+ convertOverallDifficulty: false,
1621
+ }).overallDifficulty;
1622
+ this.hitWindow = new osuBase.DroidHitWindow(od);
1058
1623
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1059
1624
  // this.csvString = `Mods,${
1060
1625
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
@@ -1237,9 +1802,8 @@ class TwoHandChecker {
1237
1802
  const prevToCurrentMovement = object
1238
1803
  .getStackedPosition(osuBase.Modes.droid)
1239
1804
  .subtract(prevObjectEndPosition);
1240
- const radius = object.getRadius(osuBase.Modes.droid);
1241
1805
  // Don't consider objects that are too close to each other.
1242
- if (prevToCurrentMovement.length <= radius) {
1806
+ if (prevToCurrentMovement.length <= object.radius) {
1243
1807
  return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1244
1808
  }
1245
1809
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
@@ -1368,7 +1932,6 @@ class TwoHandChecker {
1368
1932
  cursorTime: object.startTime,
1369
1933
  };
1370
1934
  }
1371
- const radius = object.getRadius(osuBase.Modes.droid);
1372
1935
  let hitWindow = this.hitWindow50;
1373
1936
  const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1374
1937
  // For sliders, set the hit window to as lenient as possible.
@@ -1383,7 +1946,7 @@ class TwoHandChecker {
1383
1946
  }
1384
1947
  }
1385
1948
  // TODO: what to do for head sliderbreaks?
1386
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1949
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1387
1950
  let nearestCursorIndex = 0;
1388
1951
  let nearestGroupIndex = 0;
1389
1952
  let nearestCursorGroupIndex = 0;
@@ -1391,12 +1954,12 @@ class TwoHandChecker {
1391
1954
  const minimumActiveTime = object.startTime - hitWindow;
1392
1955
  const maximumActiveTime = object.startTime + hitWindow;
1393
1956
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1394
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1957
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1395
1958
  break;
1396
1959
  }
1397
1960
  const cursorData = this.data.cursorMovement[i];
1398
1961
  for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
1399
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1962
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1400
1963
  break;
1401
1964
  }
1402
1965
  const cursorGroup = cursorData.occurrenceGroups[j];
@@ -1408,7 +1971,8 @@ class TwoHandChecker {
1408
1971
  }
1409
1972
  // Validate the down press first.
1410
1973
  const { down } = cursorGroup;
1411
- if (down.position.getDistance(objectPosition) <= radius &&
1974
+ if (down.position.getDistance(objectPosition) <=
1975
+ object.radius &&
1412
1976
  Math.abs(down.time - object.startTime) <= hitWindow) {
1413
1977
  if (objectIndex > 0) {
1414
1978
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
@@ -1479,12 +2043,13 @@ class TwoHandChecker {
1479
2043
  nearestCursorGroupIndex = k;
1480
2044
  nearestCursorTime = cursorGroup.down.time;
1481
2045
  }
1482
- if (distance <= radius) {
2046
+ if (distance <= object.radius) {
1483
2047
  break;
1484
2048
  }
1485
2049
  }
1486
2050
  }
1487
- if (nearestPosition.getDistance(objectPosition) <= radius) {
2051
+ if (nearestPosition.getDistance(objectPosition) <=
2052
+ object.radius) {
1488
2053
  break;
1489
2054
  }
1490
2055
  }
@@ -1532,7 +2097,7 @@ class TwoHandChecker {
1532
2097
  else {
1533
2098
  objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
1534
2099
  }
1535
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
2100
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1536
2101
  let nearestCursorIndex = 0;
1537
2102
  let nearestGroupIndex = 0;
1538
2103
  let nearestCursorGroupIndex = 0;
@@ -1657,12 +2222,13 @@ class SliderCheeseChecker {
1657
2222
  this.beatmap = beatmap;
1658
2223
  this.data = data;
1659
2224
  this.difficultyAttributes = difficultyAttributes;
1660
- const stats = new osuBase.MapStats({
1661
- od: this.beatmap.difficulty.od,
2225
+ const od = osuBase.calculateDroidDifficultyStatistics({
2226
+ overallDifficulty: beatmap.difficulty.od,
1662
2227
  mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
1663
2228
  !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1664
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1665
- 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));
1666
2232
  }
1667
2233
  /**
1668
2234
  * Checks if relevant sliders in the given beatmap was cheesed.
@@ -1689,12 +2255,7 @@ class SliderCheeseChecker {
1689
2255
  const cheesedDifficultyRatings = [];
1690
2256
  // Current loop indices are stored for efficiency.
1691
2257
  const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1692
- const circleSize = new osuBase.MapStats({
1693
- cs: this.beatmap.difficulty.cs,
1694
- mods: this.difficultyAttributes.mods,
1695
- }).calculate({ mode: osuBase.Modes.droid }).cs;
1696
- const scale = osuBase.CircleSizeCalculator.standardCSToStandardScale(circleSize);
1697
- const acceptableRadius = 64 * scale * 2.4;
2258
+ const acceptableRadius = objects[0].radius * 2;
1698
2259
  // Sort difficult sliders by index so that cursor loop indices work properly.
1699
2260
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
1700
2261
  .slice()
@@ -1708,12 +2269,7 @@ class SliderCheeseChecker {
1708
2269
  objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1709
2270
  continue;
1710
2271
  }
1711
- let object = objects[difficultSlider.index];
1712
- if (object.droidScale !== scale) {
1713
- // Deep clone the object so that we can assign scale properly.
1714
- object = osuBase.Utils.deepCopy(object);
1715
- object.droidScale = scale;
1716
- }
2272
+ const object = objects[difficultSlider.index];
1717
2273
  const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1718
2274
  // These time boundaries should consider the delta time between the previous and next
1719
2275
  // object as well as their hit accuracy. However, they are somewhat complicated to
@@ -1812,14 +2368,6 @@ class SliderCheeseChecker {
1812
2368
  continue;
1813
2369
  }
1814
2370
  const nestedObject = object.nestedHitObjects[i];
1815
- nestedObject.droidScale = scale;
1816
- // Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
1817
- // In that case, its time is assumed to be 36ms behind the slider's end time. However, that
1818
- // is not the case for osu!droid.
1819
- if (nestedObject instanceof osuBase.SliderTail) {
1820
- nestedObject.startTime = object.endTime;
1821
- nestedObject.endTime = object.endTime;
1822
- }
1823
2371
  const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1824
2372
  while (occurrenceLoopIndex < allOccurrences.length &&
1825
2373
  allOccurrences[occurrenceLoopIndex].time <
@@ -1936,13 +2484,14 @@ class ReplayAnalyzer {
1936
2484
  */
1937
2485
  hasBeenCheckedFor2Hand = false;
1938
2486
  /**
1939
- * Whether this repla has been checked against slider cheesing.
2487
+ * Whether this replay has been checked against slider cheesing.
1940
2488
  */
1941
2489
  hasBeenCheckedForSliderCheesing = false;
1942
2490
  /**
1943
2491
  * The amount of two-handed objects.
1944
2492
  */
1945
2493
  twoHandedNoteCount = 0;
2494
+ convertedBeatmap;
1946
2495
  // Sizes of primitive data types in Java (in bytes)
1947
2496
  BYTE_LENGTH = 1;
1948
2497
  SHORT_LENGTH = 2;
@@ -2103,11 +2652,33 @@ class ReplayAnalyzer {
2103
2652
  determineRank();
2104
2653
  }
2105
2654
  if (resultObject.replayVersion >= 4) {
2106
- const s = rawObject[7].split("|");
2107
- resultObject.speedModification =
2108
- parseFloat(s[0].replace("x", "")) || 1;
2109
- if (s.length > 1) {
2110
- resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
2655
+ const str = rawObject[7].split("|");
2656
+ for (const s of str) {
2657
+ switch (true) {
2658
+ // Forced stats
2659
+ case s.startsWith("CS"):
2660
+ resultObject.forceCS = parseFloat(s.replace("CS", ""));
2661
+ break;
2662
+ case s.startsWith("AR"):
2663
+ resultObject.forceAR = parseFloat(s.replace("AR", ""));
2664
+ break;
2665
+ case s.startsWith("OD"):
2666
+ resultObject.forceOD = parseFloat(s.replace("OD", ""));
2667
+ break;
2668
+ case s.startsWith("HP"):
2669
+ resultObject.forceHP = parseFloat(s.replace("HP", ""));
2670
+ break;
2671
+ // FL follow delay
2672
+ case s.startsWith("FLD"):
2673
+ resultObject.flashlightFollowDelay =
2674
+ parseFloat(s.replace("FLD", "")) || 0.12;
2675
+ break;
2676
+ // Speed multiplier
2677
+ case s.startsWith("x"):
2678
+ resultObject.speedMultiplier =
2679
+ parseFloat(s.replace("x", "")) || 1;
2680
+ break;
2681
+ }
2111
2682
  }
2112
2683
  }
2113
2684
  let bufferIndex;
@@ -2272,12 +2843,12 @@ class ReplayAnalyzer {
2272
2843
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2273
2844
  ? this.beatmap.beatmap
2274
2845
  : this.beatmap;
2275
- const objects = beatmap.hitObjects.objects;
2276
- const stats = new osuBase.MapStats({
2277
- od: beatmap.difficulty.od,
2846
+ const { objects } = beatmap.hitObjects;
2847
+ const od = osuBase.calculateDroidDifficultyStatistics({
2848
+ overallDifficulty: beatmap.difficulty.od,
2278
2849
  mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2279
- }).calculate();
2280
- 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));
2281
2852
  // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2282
2853
  // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2283
2854
  //
@@ -2291,8 +2862,8 @@ class ReplayAnalyzer {
2291
2862
  accuracies.push(0);
2292
2863
  continue;
2293
2864
  }
2294
- const accuracy = v.accuracy;
2295
- if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2865
+ const { accuracy } = v;
2866
+ if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2296
2867
  accuracies.push(0);
2297
2868
  continue;
2298
2869
  }
@@ -2355,9 +2926,32 @@ class ReplayAnalyzer {
2355
2926
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2356
2927
  return;
2357
2928
  }
2358
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap instanceof osuBase.Beatmap
2359
- ? this.beatmap
2360
- : 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);
2361
2955
  const result = threeFingerChecker.check();
2362
2956
  this.is3Finger = result.is3Finger;
2363
2957
  this.tapPenalty = result.penalty;
@@ -2389,9 +2983,30 @@ class ReplayAnalyzer {
2389
2983
  if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2390
2984
  return;
2391
2985
  }
2392
- const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
2393
- ? this.beatmap
2394
- : 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);
2395
3010
  this.sliderCheesePenalty = sliderCheeseChecker.check();
2396
3011
  this.hasBeenCheckedForSliderCheesing = true;
2397
3012
  }
@@ -2442,6 +3057,7 @@ class ReplayObjectData {
2442
3057
  exports.CursorData = CursorData;
2443
3058
  exports.CursorOccurrence = CursorOccurrence;
2444
3059
  exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
3060
+ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2445
3061
  exports.ReplayAnalyzer = ReplayAnalyzer;
2446
3062
  exports.ReplayData = ReplayData;
2447
3063
  exports.ReplayObjectData = ReplayObjectData;