@rian8337/osu-droid-replay-analyzer 2.0.0-alpha.3 → 2.0.0-alpha.30

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
@@ -29,25 +29,45 @@ function _interopNamespace(e) {
29
29
 
30
30
  var javaDeserialization__namespace = /*#__PURE__*/_interopNamespace(javaDeserialization);
31
31
 
32
+ /**
33
+ * Represents a cursor's occurrence.
34
+ */
35
+ class CursorOccurrence {
36
+ /**
37
+ * The time of this occurrence.
38
+ */
39
+ time;
40
+ /**
41
+ * The position of the occurrence.
42
+ */
43
+ position;
44
+ /**
45
+ * The movement ID of the occurrence.
46
+ */
47
+ id;
48
+ constructor(time, x, y, id) {
49
+ this.time = time;
50
+ this.position = new osuBase.Vector2(x, y);
51
+ this.id = id;
52
+ }
53
+ }
54
+
32
55
  /**
33
56
  * Represents a cursor instance in an osu!droid replay.
34
57
  *
35
- * Stores cursor movement data such as x and y coordinates, movement size, etc.
58
+ * Stores cursor movement data in the form of `CursorOccurrence`s.
36
59
  *
37
60
  * This is used when analyzing replays using replay analyzer.
38
61
  */
39
62
  class CursorData {
40
- size;
41
- time;
42
- x;
43
- y;
44
- id;
63
+ /**
64
+ * The occurrences of this cursor instance.
65
+ */
66
+ occurrences = [];
45
67
  constructor(values) {
46
- this.size = values.size;
47
- this.time = values.time;
48
- this.x = values.x;
49
- this.y = values.y;
50
- this.id = values.id;
68
+ for (let i = 0; i < values.size; ++i) {
69
+ this.occurrences.push(new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]));
70
+ }
51
71
  }
52
72
  }
53
73
 
@@ -352,14 +372,16 @@ class ThreeFingerChecker {
352
372
  }
353
373
  this.getAccurateBreakPoints();
354
374
  this.filterCursorInstances();
355
- if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
375
+ if (this.downCursorInstances.filter((v) => v.occurrences.length > 0)
376
+ .length <= 3) {
356
377
  return { is3Finger: false, penalty: 1 };
357
378
  }
358
379
  this.getBeatmapSections();
359
380
  this.detectDragPlay();
360
381
  this.getDetailedBeatmapSections();
361
382
  this.preventAccidentalTaps();
362
- if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
383
+ if (this.downCursorInstances.filter((v) => v.occurrences.length > 0)
384
+ .length <= 3) {
363
385
  return { is3Finger: false, penalty: 1 };
364
386
  }
365
387
  this.calculateNerfFactors();
@@ -478,22 +500,18 @@ class ThreeFingerChecker {
478
500
  y: [],
479
501
  id: [],
480
502
  });
481
- for (let j = 0; j < cursorInstance.size; ++j) {
482
- if (cursorInstance.id[j] !== exports.movementType.DOWN) {
503
+ for (let j = 0; j < cursorInstance.occurrences.length; ++j) {
504
+ if (cursorInstance.occurrences[j].id !== exports.movementType.DOWN) {
483
505
  continue;
484
506
  }
485
- const time = cursorInstance.time[j];
507
+ const time = cursorInstance.occurrences[j].time;
486
508
  if (time < firstObjectHitTime || time > lastObjectHitTime) {
487
509
  continue;
488
510
  }
489
511
  if (this.breakPointAccurateTimes.some((v) => time >= v.startTime && time <= v.endTime)) {
490
512
  continue;
491
513
  }
492
- ++newCursorData.size;
493
- newCursorData.time.push(time);
494
- newCursorData.x.push(cursorInstance.x[j]);
495
- newCursorData.y.push(cursorInstance.y[j]);
496
- newCursorData.id.push(cursorInstance.id[j]);
514
+ newCursorData.occurrences.push(new CursorOccurrence(time, cursorInstance.occurrences[j].position.x, cursorInstance.occurrences[j].position.y, cursorInstance.occurrences[j].id));
497
515
  }
498
516
  this.downCursorInstances.push(newCursorData);
499
517
  }
@@ -579,16 +597,18 @@ class ThreeFingerChecker {
579
597
  const cursorIndexes = [];
580
598
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
581
599
  const c = this.data.cursorMovement[i];
582
- if (c.size === 0) {
600
+ if (c.occurrences.length === 0) {
583
601
  continue;
584
602
  }
585
603
  // Do not include cursors that don't have an occurence in this section
586
604
  // this speeds up checking process.
587
- if (c.time.filter((v) => v >= firstObjectMinHitTime && v <= lastObjectMaxHitTime).length === 0) {
605
+ if (c.occurrences.filter((v) => v.time >= firstObjectMinHitTime &&
606
+ v.time <= lastObjectMaxHitTime).length === 0) {
588
607
  continue;
589
608
  }
590
609
  // If this cursor instance doesn't move, it's not the cursor instance we want.
591
- if (c.id.filter((v) => v === exports.movementType.MOVE).length === 0) {
610
+ if (c.occurrences.filter((v) => v.id === exports.movementType.MOVE)
611
+ .length === 0) {
592
612
  continue;
593
613
  }
594
614
  cursorIndexes.push(i);
@@ -625,27 +645,28 @@ class ThreeFingerChecker {
625
645
  // therefore the game emulates the movement between
626
646
  // movementType.MOVE cursors.
627
647
  const hitTime = o.object.startTime + s.accuracy;
628
- const nextHitIndex = c.time.findIndex((v) => v >= hitTime);
648
+ const nextHitIndex = c.occurrences.findIndex((v) => v.time >= hitTime);
629
649
  const hitIndex = nextHitIndex - 1;
630
650
  if (hitIndex <= -1) {
631
651
  cursorIndexes[j] = -1;
632
652
  continue;
633
653
  }
634
- if (c.id[hitIndex] === exports.movementType.UP) {
654
+ if (c.occurrences[hitIndex].id === exports.movementType.UP) {
635
655
  cursorIndexes[j] = -1;
636
656
  continue;
637
657
  }
638
- const cursorPosition = new osuBase.Vector2(c.x[hitIndex], c.y[hitIndex]);
658
+ const cursorPosition = new osuBase.Vector2(c.occurrences[hitIndex].position.x, c.occurrences[hitIndex].position.y);
639
659
  let isInObject = false;
640
- if (c.id[nextHitIndex] === exports.movementType.MOVE ||
641
- c.id[hitIndex] === exports.movementType.MOVE) {
660
+ if (c.occurrences[nextHitIndex].id === exports.movementType.MOVE ||
661
+ c.occurrences[hitIndex].id === exports.movementType.MOVE) {
642
662
  // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
643
663
  // This minimizes rounding error.
644
- for (let mSecPassed = c.time[hitIndex]; mSecPassed <= c.time[nextHitIndex]; ++mSecPassed) {
645
- const t = (mSecPassed - c.time[nextHitIndex]) /
646
- (c.time[hitIndex] - c.time[nextHitIndex]);
647
- cursorPosition.x = osuBase.Interpolation.lerp(c.x[hitIndex], c.x[nextHitIndex], t);
648
- cursorPosition.y = osuBase.Interpolation.lerp(c.y[hitIndex], c.y[nextHitIndex], t);
664
+ for (let mSecPassed = c.occurrences[hitIndex].time; mSecPassed <= c.occurrences[nextHitIndex].time; ++mSecPassed) {
665
+ const t = (mSecPassed - c.occurrences[nextHitIndex].time) /
666
+ (c.occurrences[hitIndex].time -
667
+ c.occurrences[nextHitIndex].time);
668
+ cursorPosition.x = osuBase.Interpolation.lerp(c.occurrences[hitIndex].position.x, c.occurrences[nextHitIndex].position.x, t);
669
+ cursorPosition.y = osuBase.Interpolation.lerp(c.occurrences[hitIndex].position.y, c.occurrences[nextHitIndex].position.y, t);
649
670
  if (o.object.stackedPosition.getDistance(cursorPosition) <= o.object.radius) {
650
671
  isInObject = true;
651
672
  break;
@@ -718,32 +739,24 @@ class ThreeFingerChecker {
718
739
  * unnecessary taps.
719
740
  */
720
741
  preventAccidentalTaps() {
721
- let filledCursorAmount = this.downCursorInstances.filter((v) => v.size > 0).length;
742
+ let filledCursorAmount = this.downCursorInstances.filter((v) => v.occurrences.length > 0).length;
722
743
  if (filledCursorAmount <= 3) {
723
744
  return;
724
745
  }
725
746
  const objects = this.map.objects;
726
- const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.size, 0);
747
+ const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.occurrences.length, 0);
727
748
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
728
749
  if (filledCursorAmount <= 3) {
729
750
  break;
730
751
  }
731
752
  const cursorInstance = this.downCursorInstances[i];
732
753
  // Use an estimation for accidental tap threshold.
733
- if (cursorInstance.size <=
754
+ if (cursorInstance.occurrences.length <=
734
755
  Math.ceil(objects.length / this.accidentalTapThreshold) &&
735
- cursorInstance.size / totalCursorAmount <
756
+ cursorInstance.occurrences.length / totalCursorAmount <
736
757
  this.threeFingerRatioThreshold * 2) {
737
758
  --filledCursorAmount;
738
- for (const property in cursorInstance) {
739
- const prop = property;
740
- if (Array.isArray(cursorInstance[prop])) {
741
- cursorInstance[prop].length = 0;
742
- }
743
- else {
744
- cursorInstance[prop] = 0;
745
- }
746
- }
759
+ cursorInstance.occurrences.length = 0;
747
760
  }
748
761
  this.downCursorInstances[i] = cursorInstance;
749
762
  }
@@ -773,13 +786,9 @@ class ThreeFingerChecker {
773
786
  : this.hitWindow.hitWindowFor50(isPrecise));
774
787
  // Filter cursor instances during section.
775
788
  this.downCursorInstances.forEach((c) => {
776
- const i = c.time.findIndex((t) => t >= startTime);
789
+ const i = c.occurrences.findIndex((t) => t.time >= startTime);
777
790
  if (i !== -1) {
778
- c.size -= i;
779
- c.time.splice(0, i);
780
- c.x.splice(0, i);
781
- c.y.splice(0, i);
782
- c.id.splice(0, i);
791
+ c.occurrences = c.occurrences.slice(i);
783
792
  }
784
793
  });
785
794
  const cursorAmounts = [];
@@ -791,13 +800,13 @@ class ThreeFingerChecker {
791
800
  }
792
801
  const cursorData = this.downCursorInstances[i];
793
802
  let amount = 0;
794
- for (let j = 0; j < cursorData.size; ++j) {
795
- if (cursorData.time[j] >= startTime &&
796
- cursorData.time[j] <= endTime) {
803
+ for (let j = 0; j < cursorData.occurrences.length; ++j) {
804
+ if (cursorData.occurrences[j].time >= startTime &&
805
+ cursorData.occurrences[j].time <= endTime) {
797
806
  ++amount;
798
807
  cursorVectorTimes.push({
799
- vector: new osuBase.Vector2(cursorData.x[j], cursorData.y[j]),
800
- time: cursorData.time[j],
808
+ vector: new osuBase.Vector2(cursorData.occurrences[j].position.x, cursorData.occurrences[j].position.y),
809
+ time: cursorData.occurrences[j].time,
801
810
  });
802
811
  }
803
812
  }
@@ -872,7 +881,7 @@ class ThreeFingerChecker {
872
881
  const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
873
882
  ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
874
883
  Math.pow(((index + 1) * value * objectCount) /
875
- this.strainNoteCount, 0.8), 1)
884
+ this.strainNoteCount, 0.9), 1)
876
885
  : Math.pow(validPresses.reduce((acc, value, index) => acc +
877
886
  Math.pow(((index + 1) *
878
887
  (value.count /
@@ -907,20 +916,36 @@ class ThreeFingerChecker {
907
916
  */
908
917
  class IndexedHitObject {
909
918
  /**
910
- * The index of the cursor that hits the hitobject.
919
+ * The accepted index of the cursor that hits the hitobject.
920
+ */
921
+ acceptedCursorIndex;
922
+ /**
923
+ * The actual index of the cursor that hits the hitobject.
924
+ */
925
+ actualCursorIndex;
926
+ /**
927
+ * The occurrence index of the cursor that hits the hitobject.
911
928
  */
912
- cursorIndex;
929
+ occurrenceIndex;
930
+ /**
931
+ * If this is a slider, whether the slider was cheesed.
932
+ */
933
+ sliderCheesed = false;
913
934
  /**
914
935
  * The underlying difficulty hitobject.
915
936
  */
916
937
  object;
917
938
  /**
918
939
  * @param object The underlying difficulty hitobject.
919
- * @param cursorIndex The index of the cursor that hits the hitobject.
940
+ * @param acceptedCursorIndex The accepted index of the cursor that hits the hitobject.
941
+ * @param actualCursorIndex The actual index of the cursor that hits the hitobject.
942
+ * @param occurrenceIndex The occurrence index of the cursor that hits the hitobject.
920
943
  */
921
- constructor(object, cursorIndex) {
944
+ constructor(object, acceptedCursorIndex, actualCursorIndex, occurrenceIndex) {
922
945
  this.object = object;
923
- this.cursorIndex = cursorIndex;
946
+ this.acceptedCursorIndex = acceptedCursorIndex;
947
+ this.actualCursorIndex = actualCursorIndex;
948
+ this.occurrenceIndex = occurrenceIndex;
924
949
  }
925
950
  }
926
951
 
@@ -936,10 +961,6 @@ class TwoHandChecker {
936
961
  * The data of the replay.
937
962
  */
938
963
  data;
939
- /**
940
- * A cursor data array that only contains `movementType.DOWN` and `movementType.MOVE` movement ID occurrences.
941
- */
942
- downMoveCursorInstances = [];
943
964
  /**
944
965
  * The hitobjects of the beatmap that have been assigned with their respective cursor index.
945
966
  */
@@ -974,177 +995,412 @@ class TwoHandChecker {
974
995
  * Checks if a beatmap is two-handed.
975
996
  */
976
997
  check() {
977
- this.filterCursorInstances();
978
- if (this.downMoveCursorInstances.filter((v) => v.size > 0).length <= 1) {
979
- return false;
998
+ if (this.data.cursorMovement.filter((v) => v.occurrences.length > 0)
999
+ .length <= 1) {
1000
+ return { is2Hand: false, cursorIndexes: [] };
980
1001
  }
981
1002
  this.indexHitObjects();
982
1003
  this.applyPenalty();
983
- return true;
984
- }
985
- /**
986
- * Filters the original cursor instances, returning only those with `movementType.DOWN` and `movementType.MOVE` movement ID.
987
- */
988
- filterCursorInstances() {
989
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
990
- const cursorInstance = this.data.cursorMovement[i];
991
- const newCursorData = {
992
- size: 0,
993
- time: [],
994
- x: [],
995
- y: [],
996
- id: [],
997
- };
998
- for (let j = 0; j < cursorInstance.size; ++j) {
999
- if (cursorInstance.id[j] === exports.movementType.UP) {
1000
- continue;
1001
- }
1002
- ++newCursorData.size;
1003
- newCursorData.time.push(cursorInstance.time[j]);
1004
- newCursorData.x.push(cursorInstance.x[j]);
1005
- newCursorData.y.push(cursorInstance.y[j]);
1006
- newCursorData.id.push(cursorInstance.id[j]);
1007
- }
1008
- this.downMoveCursorInstances.push(newCursorData);
1004
+ const indexes = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1005
+ for (const object of this.indexedHitObjects) {
1006
+ ++indexes[object.acceptedCursorIndex];
1009
1007
  }
1008
+ return {
1009
+ is2Hand: indexes.filter((v) => v > 0).length !== 1,
1010
+ cursorIndexes: this.indexedHitObjects.map((v) => v.acceptedCursorIndex),
1011
+ };
1010
1012
  }
1011
1013
  /**
1012
1014
  * Converts hitobjects into indexed hit objects.
1013
1015
  */
1014
1016
  indexHitObjects() {
1015
- const objects = this.map.objects;
1016
- const objectData = this.data.hitObjectData;
1017
+ const hitWindowOffset = this.getHitWindowOffset();
1017
1018
  const indexes = [];
1018
1019
  for (let i = 0; i < this.map.objects.length; ++i) {
1019
- const current = objects[i];
1020
- const currentData = objectData[i];
1021
- const index = this.getCursorIndex(current, currentData);
1022
- indexes.push(index);
1023
- this.indexedHitObjects.push(new IndexedHitObject(current, index));
1020
+ const indexedHitObject = this.getIndexedHitObject(i, hitWindowOffset);
1021
+ indexes.push(indexedHitObject.acceptedCursorIndex);
1022
+ this.indexedHitObjects.push(indexedHitObject);
1024
1023
  }
1025
- console.log(indexes.filter((v) => v !== -1).length, "cursors found,", indexes.filter((v) => v === -1).length, "not found");
1026
- const indexCounts = osuBase.Utils.initializeArray(this.downMoveCursorInstances.length, 0);
1024
+ const indexCounts = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1027
1025
  for (const index of indexes) {
1028
1026
  if (index === -1) {
1029
1027
  continue;
1030
1028
  }
1031
1029
  ++indexCounts[index];
1032
1030
  }
1033
- const mainCursorIndex = indexCounts.indexOf(Math.max(...indexCounts));
1031
+ const mainCursorIndex = indexCounts.length > 0
1032
+ ? indexCounts.indexOf(Math.max(...indexCounts))
1033
+ : 0;
1034
1034
  const ignoredCursorIndexes = [];
1035
1035
  for (let i = 0; i < indexCounts.length; ++i) {
1036
- if (indexCounts[i] < this.minCursorIndexCount) {
1036
+ if (indexCounts[i] < this.minCursorIndexCount &&
1037
+ i !== mainCursorIndex) {
1037
1038
  ignoredCursorIndexes.push(i);
1038
1039
  }
1039
1040
  }
1040
- this.indexedHitObjects.forEach((indexedHitObject) => {
1041
- if (indexedHitObject.cursorIndex === -1 ||
1042
- ignoredCursorIndexes.includes(indexedHitObject.cursorIndex)) {
1043
- indexedHitObject.cursorIndex = mainCursorIndex;
1041
+ // Add cursor presses that don't fulfill minimum cursor
1042
+ // count to the farthest cursor index that isn't 0.
1043
+ let defaultMinCursorCountIndex = this.data.cursorMovement.length - 1;
1044
+ for (; defaultMinCursorCountIndex > 0; --defaultMinCursorCountIndex) {
1045
+ if (indexCounts[defaultMinCursorCountIndex] >=
1046
+ this.minCursorIndexCount &&
1047
+ !ignoredCursorIndexes.includes(defaultMinCursorCountIndex)) {
1048
+ break;
1049
+ }
1050
+ }
1051
+ this.indexedHitObjects.forEach((indexedHitObject, i) => {
1052
+ if (indexedHitObject.acceptedCursorIndex === -1 ||
1053
+ indexedHitObject.actualCursorIndex === -1) {
1054
+ indexedHitObject.acceptedCursorIndex = mainCursorIndex;
1055
+ indexedHitObject.actualCursorIndex = mainCursorIndex;
1056
+ }
1057
+ if (ignoredCursorIndexes.includes(indexedHitObject.acceptedCursorIndex)) {
1058
+ indexedHitObject.acceptedCursorIndex =
1059
+ defaultMinCursorCountIndex;
1060
+ }
1061
+ // For sliders, we need to consider two cases. The first case is when the player doesn't drag
1062
+ // the slider. The second case is when the player drags the slider.
1063
+ if (indexedHitObject.object.object instanceof osuBase.Slider) {
1064
+ indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i], hitWindowOffset);
1044
1065
  }
1045
1066
  });
1046
- for (let i = 0; i < this.downMoveCursorInstances.length; ++i) {
1047
- console.log("Index", i, "count:", indexes.filter((v) => v === i).length);
1067
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1068
+ console.log("Index", i, "count:", this.indexedHitObjects.filter((v) => v.acceptedCursorIndex === i).length);
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Gets the hit window offset to be applied to `getCursorIndex`.
1073
+ */
1074
+ getHitWindowOffset() {
1075
+ const deltaTimes = [];
1076
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1077
+ const c = this.data.cursorMovement[i];
1078
+ for (let j = 0; j < c.occurrences.length; ++j) {
1079
+ if (c.occurrences[j].time <
1080
+ this.map.map.hitObjects.objects[0].startTime -
1081
+ this.hitWindow.hitWindowFor50()) {
1082
+ continue;
1083
+ }
1084
+ if (c.occurrences[j].id !== exports.movementType.MOVE) {
1085
+ continue;
1086
+ }
1087
+ const deltaTime = c.occurrences[j]?.time - c.occurrences[j - 1]?.time || 0;
1088
+ if (deltaTime > 0) {
1089
+ deltaTimes.push(deltaTime);
1090
+ }
1091
+ }
1048
1092
  }
1093
+ return Math.min(...deltaTimes);
1049
1094
  }
1050
1095
  /**
1051
1096
  * Gets the cursor index that hits the given object.
1052
1097
  *
1053
- * @param object The object to check.
1054
- * @param data The replay data of the object.
1098
+ * @param index The index of the object to check.
1099
+ * @param hitWindowOffset The offset for hit window to compensate for replay hit inaccuracies.
1055
1100
  * @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
1056
1101
  */
1057
- getCursorIndex(object, data) {
1102
+ getIndexedHitObject(index, hitWindowOffset) {
1103
+ const object = this.map.objects[index];
1104
+ const data = this.data.hitObjectData[index];
1058
1105
  if (object.object instanceof osuBase.Spinner ||
1059
1106
  data.result === exports.hitResult.RESULT_0) {
1060
- return -1;
1107
+ return new IndexedHitObject(object, -1, -1, -1);
1061
1108
  }
1062
1109
  const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1063
- let hitWindowLength;
1064
- switch (data.result) {
1065
- case exports.hitResult.RESULT_300:
1066
- hitWindowLength = this.hitWindow.hitWindowFor300(isPrecise);
1067
- break;
1068
- case exports.hitResult.RESULT_100:
1069
- hitWindowLength = this.hitWindow.hitWindowFor100(isPrecise);
1070
- break;
1071
- default:
1072
- hitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
1110
+ // For sliders, automatically set hit window to be as lenient as possible.
1111
+ let hitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
1112
+ if (!(object.object instanceof osuBase.Slider)) {
1113
+ switch (data.result) {
1114
+ case exports.hitResult.RESULT_300:
1115
+ hitWindowLength = this.hitWindow.hitWindowFor300(isPrecise);
1116
+ break;
1117
+ case exports.hitResult.RESULT_100:
1118
+ hitWindowLength = this.hitWindow.hitWindowFor100(isPrecise);
1119
+ break;
1120
+ }
1073
1121
  }
1074
- const hitTime = object.object.startTime;
1075
- const maximumHitTime = hitTime + hitWindowLength;
1076
- const minimumHitTime = hitTime - hitWindowLength;
1122
+ const startTime = object.object.startTime;
1123
+ const hitTime = startTime + data.accuracy;
1124
+ const minimumHitTime = startTime - hitWindowLength - hitWindowOffset;
1125
+ const maximumHitTime = startTime + hitWindowLength + hitWindowOffset;
1077
1126
  const cursorInformations = [];
1078
- for (let i = 0; i < this.downMoveCursorInstances.length; ++i) {
1079
- const c = this.downMoveCursorInstances[i];
1080
- let minDistance = Number.POSITIVE_INFINITY;
1081
- let minHitTime = 0;
1082
- for (let j = 0; j < c.size; ++j) {
1083
- if (c.time[j] < minimumHitTime) {
1127
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1128
+ const c = this.data.cursorMovement[i];
1129
+ if (c.occurrences.length === 0) {
1130
+ continue;
1131
+ }
1132
+ let hitTimeBeforeIndex = osuBase.MathUtils.clamp(c.occurrences.findIndex((v) => v.time >= minimumHitTime), 1, c.occurrences.length - 1) - 1;
1133
+ let hitTimeAfterIndex = c.occurrences.findIndex(
1134
+ // There is a special case for sliders where the time leniency in droid is a lot bigger compared to PC.
1135
+ // To prevent slider end time from ending earlier than hit window leniency, we use the maximum value between both.
1136
+ (v) => v.time >= Math.max(object.object.endTime, maximumHitTime));
1137
+ if (hitTimeAfterIndex === -1) {
1138
+ // Maximum hit time or object end time may be out of bounds for every presses.
1139
+ // We set the index to the latest cursor occurrence if that happens.
1140
+ hitTimeAfterIndex = c.occurrences.length;
1141
+ }
1142
+ --hitTimeAfterIndex;
1143
+ // Sometimes a `movementType.UP` instance occurs at the same time as a `movementType.MOVE`
1144
+ // or a cursor is recorded twice in one time, therefore this check is required.
1145
+ while (c.occurrences[hitTimeBeforeIndex]?.time ===
1146
+ c.occurrences[hitTimeBeforeIndex - 1]?.time &&
1147
+ hitTimeBeforeIndex > 0) {
1148
+ --hitTimeBeforeIndex;
1149
+ }
1150
+ // We track the cursor movement along those indexes.
1151
+ // Current cursor position is in `hitTimeBeforeIndex`.
1152
+ let distance = Number.POSITIVE_INFINITY;
1153
+ let j = hitTimeBeforeIndex;
1154
+ for (j; j <= hitTimeAfterIndex; ++j) {
1155
+ const occurrence = c.occurrences[j];
1156
+ const nextOccurrence = c.occurrences[j + 1];
1157
+ const cursorPosition = occurrence.position;
1158
+ if (occurrence.time < minimumHitTime &&
1159
+ nextOccurrence?.id !== exports.movementType.MOVE) {
1084
1160
  continue;
1085
1161
  }
1086
- // For some reason, some cursor instances repeat itself,
1087
- // so just skip it to save time.
1088
- if (c.time[j + 1] === c.time[j]) {
1162
+ if (occurrence.time > hitTime + hitWindowOffset) {
1163
+ // Set distance to minimum just for the last.
1164
+ if (occurrence.id !== exports.movementType.UP) {
1165
+ distance = Math.min(distance, object.object.stackedPosition.getDistance(cursorPosition));
1166
+ }
1167
+ break;
1168
+ }
1169
+ if (occurrence.id === exports.movementType.UP) {
1089
1170
  continue;
1090
1171
  }
1091
- if (c.time[j - 1] > maximumHitTime) {
1092
- break;
1172
+ distance =
1173
+ object.object.stackedPosition.getDistance(cursorPosition);
1174
+ if (nextOccurrence?.id === exports.movementType.MOVE &&
1175
+ occurrence.time !== nextOccurrence.time &&
1176
+ !occurrence.position.equals(nextOccurrence.position)) {
1177
+ // If next cursor is a `move` instance and it doesn't go out of time
1178
+ // range, we interpolate cursor position between two occurrences.
1179
+ const nextPosition = nextOccurrence.position;
1180
+ const displacement = nextPosition.subtract(cursorPosition);
1181
+ for (let mSecPassed = Math.max(minimumHitTime, occurrence.time); mSecPassed <= Math.min(hitTime, nextOccurrence.time); ++mSecPassed) {
1182
+ const progress = (mSecPassed - occurrence.time) /
1183
+ (nextOccurrence.time - occurrence.time);
1184
+ distance = object.object.stackedPosition.getDistance(cursorPosition.add(displacement.scale(progress)));
1185
+ }
1093
1186
  }
1094
- let hitPosition = new osuBase.Vector2(c.x[j], c.y[j]);
1095
- let distanceToObject = object.object.stackedPosition.getDistance(hitPosition);
1096
- if (minDistance > distanceToObject) {
1097
- minDistance = distanceToObject;
1098
- minHitTime = c.time[j];
1187
+ }
1188
+ if (distance > object.object.radius) {
1189
+ continue;
1190
+ }
1191
+ // The case for a one-handed object is that there will be a slight movement in the cursor towards
1192
+ // the next object in fast patterns. We should not be worried about slow patterns as they will only
1193
+ // make a minimal difference and aim strain threshold should filter them out.
1194
+ // In order to verify if the player does that, we check if the movement towards the next
1195
+ // object is sufficient enough to be two-handed. This is done by checking if the movement
1196
+ // from the current object to the next object and the movement from the current object
1197
+ // to the significant move cursor occurrence produces an angle that is acute enough.
1198
+ // let isAngleFulfilled: boolean = false;
1199
+ // Aside of angles, we need to consider if the player dragged from the previous object to the current object.
1200
+ let isDragged = false;
1201
+ // Get the latest down or movement cursor occurrence.
1202
+ while (c.occurrences[j]?.id === exports.movementType.UP &&
1203
+ j > hitTimeBeforeIndex) {
1204
+ --j;
1205
+ }
1206
+ if (object.object instanceof osuBase.Circle) {
1207
+ // For circles, we only need to consider the actual press on the circle.
1208
+ // Therefore, we need to get the latest down cursor occurrence instead.
1209
+ while (c.occurrences[j]?.id !== exports.movementType.DOWN &&
1210
+ j > hitTimeBeforeIndex) {
1211
+ --j;
1099
1212
  }
1100
- minDistance = Math.min(minDistance, object.object.stackedPosition.getDistance(hitPosition));
1101
- if (c.id[j + 1] === exports.movementType.MOVE ||
1102
- c.id[j] === exports.movementType.MOVE) {
1103
- // Interpolate cursor position between two occurrences
1104
- const initialPosition = new osuBase.Vector2(c.x[j], c.y[j]);
1105
- const nextPosition = new osuBase.Vector2(c.x[j + 1], c.y[j + 1]);
1106
- const displacement = nextPosition.subtract(initialPosition);
1107
- for (let mSecPassed = c.time[j]; mSecPassed <= Math.min(c.time[j + 1], maximumHitTime); ++mSecPassed) {
1108
- const progress = (mSecPassed - c.time[j]) /
1109
- (c.time[j + 1] - c.time[j]);
1110
- hitPosition = initialPosition.add(displacement.scale(progress));
1111
- distanceToObject =
1112
- object.object.stackedPosition.getDistance(hitPosition);
1113
- if (minDistance > distanceToObject) {
1114
- minDistance = distanceToObject;
1115
- minHitTime = mSecPassed;
1116
- }
1213
+ }
1214
+ // Theoretically there can only be 1 up occurrence, but this is a
1215
+ // consideration if the user manually adds cursor occurrences.
1216
+ if (c.occurrences[j]?.id === exports.movementType.UP) {
1217
+ ++j;
1218
+ }
1219
+ // Some move instances move in the exact same place. Not sure why, most likely
1220
+ // because the position is recorded as int in the game and the movement is too small to
1221
+ // convert into +1 or -1.
1222
+ // let nextSignificantOccurrenceIndex: number = j + 1;
1223
+ // while (
1224
+ // c.occurrences[j] &&
1225
+ // c.occurrences[nextSignificantOccurrenceIndex] &&
1226
+ // c.occurrences[j].position.equals(
1227
+ // c.occurrences[nextSignificantOccurrenceIndex].position
1228
+ // )
1229
+ // ) {
1230
+ // ++nextSignificantOccurrenceIndex;
1231
+ // }
1232
+ // const nextSignificantOccurrence: CursorOccurrence =
1233
+ // c.occurrences[nextSignificantOccurrenceIndex];
1234
+ // const next: DifficultyHitObject | RebalanceDifficultyHitObject =
1235
+ // this.map.objects[index + 1];
1236
+ // Angle detection.
1237
+ /* if (nextSignificantOccurrence?.id === movementType.MOVE && next) {
1238
+ // Get the object's actual end position.
1239
+ let actualEndPosition: Vector2 =
1240
+ object.object.stackedEndPosition;
1241
+
1242
+ if (
1243
+ object.object instanceof Slider &&
1244
+ object.object.lazyEndPosition
1245
+ ) {
1246
+ // For sliders, we take the closest distance between the lazy end position
1247
+ // and stacked end position towards the next significant cursor occurrence.
1248
+ // This assumes that the player takes the simpler movement.
1249
+ const lazyEndDistance: number =
1250
+ object.object.lazyEndPosition.getDistance(
1251
+ nextSignificantOccurrence.position
1252
+ );
1253
+ const actualEndDistance: number =
1254
+ object.object.stackedEndPosition.getDistance(
1255
+ nextSignificantOccurrence.position
1256
+ );
1257
+
1258
+ if (lazyEndDistance < actualEndDistance) {
1259
+ actualEndPosition = object.object.lazyEndPosition;
1117
1260
  }
1118
1261
  }
1262
+
1263
+ // Get the movement vector towards the next cursor occurrence by subtracting
1264
+ // the next cursor occurrence with the object's position.
1265
+ const movementVec: Vector2 =
1266
+ nextSignificantOccurrence.position.subtract(
1267
+ actualEndPosition
1268
+ );
1269
+
1270
+ const currentToNext: Vector2 =
1271
+ next.object.stackedPosition.subtract(actualEndPosition);
1272
+
1273
+ const dot: number = currentToNext.dot(movementVec);
1274
+ const det: number =
1275
+ currentToNext.x * movementVec.y -
1276
+ currentToNext.y * movementVec.x;
1277
+
1278
+ const movementToNextAngle: number = Math.abs(
1279
+ Math.atan2(det, dot)
1280
+ );
1281
+
1282
+ isAngleFulfilled = movementToNextAngle < Math.PI / 6;
1283
+ } */
1284
+ // Dragging detection.
1285
+ let dragTimeThreshold = 0;
1286
+ const prev = this.map.objects[index - 1];
1287
+ if (prev) {
1288
+ // The previous object might be a slider, so we need to get
1289
+ // the hit data of it to get an accurate time threshold.
1290
+ const prevData = this.data.hitObjectData[index - 1];
1291
+ dragTimeThreshold = prev.object.startTime;
1292
+ if (!(prev.object instanceof osuBase.Spinner)) {
1293
+ dragTimeThreshold += prevData.accuracy;
1294
+ }
1295
+ if (prev.object instanceof osuBase.Slider) {
1296
+ dragTimeThreshold = Math.max(dragTimeThreshold, prev.object.endTime);
1297
+ }
1119
1298
  }
1120
- if (minDistance <= object.object.radius) {
1121
- cursorInformations.push({
1122
- cursorIndex: i,
1123
- hitTimeDiff: Math.abs(minHitTime - hitTime),
1124
- });
1299
+ let occurrenceStartIndex = hitTimeAfterIndex;
1300
+ while (c.occurrences[occurrenceStartIndex]?.time >=
1301
+ dragTimeThreshold &&
1302
+ occurrenceStartIndex > 0) {
1303
+ --occurrenceStartIndex;
1125
1304
  }
1305
+ // The above loop will make the start index before or right when
1306
+ // the previous object was hit or ended, but we want the index after it.
1307
+ ++occurrenceStartIndex;
1308
+ const dragOccurrences = c.occurrences.slice(occurrenceStartIndex, hitTimeAfterIndex);
1309
+ isDragged =
1310
+ dragOccurrences.length > 0 &&
1311
+ // We only care when the cursor approaches the current object. It doesn't
1312
+ // matter whether the previous object was pressed or dragged.
1313
+ dragOccurrences
1314
+ .at(-1)
1315
+ .position.getDistance(object.object.stackedPosition) <=
1316
+ object.object.radius &&
1317
+ dragOccurrences.every((v) => v.id === exports.movementType.MOVE);
1318
+ cursorInformations.push({
1319
+ // If the angle is fulfilled or the player dragged,
1320
+ // we set the cursor index to the main cursor index.
1321
+ acceptedCursorIndex: /* isAngleFulfilled || */ isDragged
1322
+ ? -1
1323
+ : i,
1324
+ actualCursorIndex: i,
1325
+ occurrenceIndex: j,
1326
+ distanceDiff: distance,
1327
+ });
1126
1328
  }
1127
- if (cursorInformations.length === 0) {
1128
- return -1;
1329
+ // Cursors have been filtered to see which of them is inside the object.
1330
+ // Now we look at which cursor is closest to the center of the object.
1331
+ const minDistanceDiff = Math.min(...cursorInformations.map((v) => v.distanceDiff));
1332
+ const acceptedCursorInformation = cursorInformations.find((c) => c.distanceDiff === minDistanceDiff);
1333
+ return new IndexedHitObject(object, acceptedCursorInformation?.acceptedCursorIndex ?? -1, acceptedCursorInformation?.actualCursorIndex ?? -1, acceptedCursorInformation?.occurrenceIndex ?? -1);
1334
+ }
1335
+ /**
1336
+ * Checks whether a slider was cheesed.
1337
+ *
1338
+ * This is done by checking if a cursor follows a slider all the way to its end position.
1339
+ *
1340
+ * @param indexedHitObject The indexed slider.
1341
+ * @param hitData The hit data of the slider.
1342
+ * @param actualCursorIndex The actual cursor index that hit the slider.
1343
+ * @param hitWindowOffset The offset that was calculated by `getHitWindowOffset()`
1344
+ * @returns Whether the slider was cheesed.
1345
+ */
1346
+ checkSliderCheesing(indexedHitObject, hitData, hitWindowOffset) {
1347
+ if (!(indexedHitObject.object.object instanceof osuBase.Slider) ||
1348
+ hitData.result === exports.hitResult.RESULT_0) {
1349
+ return false;
1129
1350
  }
1130
- // Now we look at which cursor is closest to hit time
1131
- const minHitTimeDiff = Math.min(...cursorInformations.map((v) => {
1132
- return v.hitTimeDiff;
1133
- }));
1134
- return (cursorInformations.find((c) => c.hitTimeDiff === minHitTimeDiff)
1135
- ?.cursorIndex);
1351
+ let cursorLoopIndex = Math.max(0, indexedHitObject.occurrenceIndex);
1352
+ const c = this.data.cursorMovement[indexedHitObject.actualCursorIndex];
1353
+ const acceptableRadius = indexedHitObject.object.object.radius * 2.4;
1354
+ for (let i = 1; i < indexedHitObject.object.object.nestedHitObjects.length; ++i) {
1355
+ const tickWasHit = hitData.tickset[i - 1];
1356
+ if (!tickWasHit) {
1357
+ continue;
1358
+ }
1359
+ const object = indexedHitObject.object.object.nestedHitObjects[i];
1360
+ let j = cursorLoopIndex;
1361
+ let cursorHitTick = false;
1362
+ for (j; j < c.occurrences.length; ++j) {
1363
+ if (c.occurrences[j].time <
1364
+ object.startTime - hitWindowOffset) {
1365
+ continue;
1366
+ }
1367
+ if (c.occurrences[j].time >
1368
+ object.startTime + hitWindowOffset) {
1369
+ break;
1370
+ }
1371
+ if (c.occurrences[j].position.getDistance(object.stackedPosition) <= acceptableRadius) {
1372
+ cursorHitTick = true;
1373
+ break;
1374
+ }
1375
+ }
1376
+ if (!cursorHitTick) {
1377
+ return true;
1378
+ }
1379
+ cursorLoopIndex = j;
1380
+ }
1381
+ return false;
1136
1382
  }
1137
1383
  /**
1138
1384
  * Applies penalty to the original star rating instance.
1139
1385
  */
1140
1386
  applyPenalty() {
1141
- const beatmaps = new Array(this.downMoveCursorInstances.length);
1387
+ const beatmaps = new Array(this.data.cursorMovement.length);
1142
1388
  this.indexedHitObjects.forEach((o) => {
1143
- if (!beatmaps[o.cursorIndex]) {
1389
+ if (!beatmaps[o.acceptedCursorIndex]) {
1144
1390
  const map = osuBase.Utils.deepCopy(this.map.map);
1145
- beatmaps[o.cursorIndex] = map;
1391
+ map.hitObjects.clear();
1392
+ beatmaps[o.acceptedCursorIndex] = map;
1146
1393
  }
1147
- beatmaps[o.cursorIndex].hitObjects.add(o.object.object);
1394
+ beatmaps[o.acceptedCursorIndex].hitObjects.add(o.object.object);
1395
+ });
1396
+ // Preserve some values that aren't reasonable for them to be changed.
1397
+ const preservedValues = this.map.objects.map((v) => {
1398
+ return {
1399
+ noteDensity: v.noteDensity,
1400
+ overlappingFactor: v.overlappingFactor,
1401
+ rhythmStrain: v.rhythmStrain,
1402
+ rhythmMultiplier: v.rhythmMultiplier,
1403
+ };
1148
1404
  });
1149
1405
  this.map.objects.length = 0;
1150
1406
  beatmaps.forEach((beatmap) => {
@@ -1155,15 +1411,32 @@ class TwoHandChecker {
1155
1411
  starRating.map = beatmap;
1156
1412
  starRating.generateDifficultyHitObjects();
1157
1413
  starRating.objects[0].deltaTime =
1158
- starRating.objects[0].object.startTime -
1159
- this.indexedHitObjects[0].object.object.startTime;
1160
- starRating.objects[0].strainTime = Math.max(50, starRating.objects[0].deltaTime);
1414
+ starRating.objects[0].startTime -
1415
+ this.indexedHitObjects[0].object.startTime;
1416
+ starRating.objects[0].strainTime = Math.max(25, starRating.objects[0].deltaTime);
1161
1417
  this.map.objects.push(...starRating.objects);
1162
1418
  });
1163
- this.map.objects.sort((a, b) => {
1164
- return a.startTime - b.startTime;
1165
- });
1166
- this.map.calculateAll();
1419
+ this.map.objects.sort((a, b) => a.startTime - b.startTime);
1420
+ // Reassign preserved values before calculating.
1421
+ for (let i = 0; i < this.map.objects.length; ++i) {
1422
+ const diffObject = this.map.objects[i];
1423
+ const indexedHitObject = this.indexedHitObjects[i];
1424
+ const preservedValue = preservedValues[i];
1425
+ diffObject.noteDensity = preservedValue.noteDensity;
1426
+ diffObject.overlappingFactor = preservedValue.overlappingFactor;
1427
+ diffObject.rhythmStrain = preservedValue.rhythmStrain;
1428
+ diffObject.rhythmMultiplier = preservedValue.rhythmMultiplier;
1429
+ // Set slider travel distance to 0 if the slider was cheesed.
1430
+ if (indexedHitObject.sliderCheesed) {
1431
+ diffObject.travelDistance = 0;
1432
+ }
1433
+ }
1434
+ // Do not include rhythm skill.
1435
+ this.map.calculateAim();
1436
+ this.map.calculateTap();
1437
+ this.map.calculateFlashlight();
1438
+ this.map.calculateVisual();
1439
+ this.map.calculateTotal();
1167
1440
  }
1168
1441
  }
1169
1442
 
@@ -1219,6 +1492,12 @@ class ReplayAnalyzer {
1219
1492
  * Whether this replay has been checked against 2 hand usage.
1220
1493
  */
1221
1494
  hasBeenCheckedFor2Hand = false;
1495
+ /**
1496
+ * The cursor indexes at which each object was hit.
1497
+ *
1498
+ * This is filled after 2 hand usage has been checked.
1499
+ */
1500
+ twoHandCursorIndexes = [];
1222
1501
  // Sizes of primitive data types in Java (in bytes)
1223
1502
  BYTE_LENGTH = 1;
1224
1503
  SHORT_LENGTH = 2;
@@ -1404,42 +1683,41 @@ class ReplayAnalyzer {
1404
1683
  for (let x = 0; x < size; x++) {
1405
1684
  const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
1406
1685
  bufferCounter += this.INT_LENGTH;
1407
- const moveArray = {
1408
- size: moveSize,
1409
- time: [],
1410
- x: [],
1411
- y: [],
1412
- id: [],
1413
- };
1686
+ const time = [];
1687
+ const x = [];
1688
+ const y = [];
1689
+ const id = [];
1414
1690
  for (let i = 0; i < moveSize; i++) {
1415
- moveArray.time[i] = replayDataBuffer.readInt32BE(bufferCounter);
1691
+ time[i] = replayDataBuffer.readInt32BE(bufferCounter);
1416
1692
  bufferCounter += this.INT_LENGTH;
1417
- moveArray.id[i] = moveArray.time[i] & 3;
1418
- moveArray.time[i] >>= 2;
1419
- if (moveArray.id[i] !== exports.movementType.UP) {
1693
+ id[i] = time[i] & 3;
1694
+ time[i] >>= 2;
1695
+ if (id[i] !== exports.movementType.UP) {
1420
1696
  if (resultObject.replayVersion >= 5) {
1421
- moveArray.x[i] =
1422
- replayDataBuffer.readFloatBE(bufferCounter);
1697
+ x[i] = replayDataBuffer.readFloatBE(bufferCounter);
1423
1698
  bufferCounter += this.FLOAT_LENGTH;
1424
- moveArray.y[i] =
1425
- replayDataBuffer.readFloatBE(bufferCounter);
1699
+ y[i] = replayDataBuffer.readFloatBE(bufferCounter);
1426
1700
  bufferCounter += this.FLOAT_LENGTH;
1427
1701
  }
1428
1702
  else {
1429
- moveArray.x[i] =
1430
- replayDataBuffer.readInt16BE(bufferCounter);
1703
+ x[i] = replayDataBuffer.readInt16BE(bufferCounter);
1431
1704
  bufferCounter += this.SHORT_LENGTH;
1432
- moveArray.y[i] =
1433
- replayDataBuffer.readInt16BE(bufferCounter);
1705
+ y[i] = replayDataBuffer.readInt16BE(bufferCounter);
1434
1706
  bufferCounter += this.SHORT_LENGTH;
1435
1707
  }
1436
1708
  }
1437
1709
  else {
1438
- moveArray.x[i] = -1;
1439
- moveArray.y[i] = -1;
1710
+ x[i] = -1;
1711
+ y[i] = -1;
1440
1712
  }
1441
1713
  }
1442
- resultObject.cursorMovement.push(moveArray);
1714
+ resultObject.cursorMovement.push(new CursorData({
1715
+ size: moveSize,
1716
+ time: time,
1717
+ x: x,
1718
+ y: y,
1719
+ id: id,
1720
+ }));
1443
1721
  }
1444
1722
  const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
1445
1723
  bufferCounter += this.INT_LENGTH;
@@ -1685,7 +1963,9 @@ class ReplayAnalyzer {
1685
1963
  return;
1686
1964
  }
1687
1965
  const twoHandChecker = new TwoHandChecker(this.map, this.data);
1688
- this.is2Hand = twoHandChecker.check();
1966
+ const result = twoHandChecker.check();
1967
+ this.is2Hand = result.is2Hand;
1968
+ this.twoHandCursorIndexes = result.cursorIndexes;
1689
1969
  this.hasBeenCheckedFor2Hand = true;
1690
1970
  }
1691
1971
  }
@@ -1720,6 +2000,7 @@ class ReplayObjectData {
1720
2000
  }
1721
2001
 
1722
2002
  exports.CursorData = CursorData;
2003
+ exports.CursorOccurrence = CursorOccurrence;
1723
2004
  exports.ReplayAnalyzer = ReplayAnalyzer;
1724
2005
  exports.ReplayData = ReplayData;
1725
2006
  exports.ReplayObjectData = ReplayObjectData;