@rian8337/osu-droid-replay-analyzer 4.0.0-beta.9 → 4.0.0-beta.91

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
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  var osuBase = require('@rian8337/osu-base');
4
- var osuDifficultyCalculator = require('@rian8337/osu-difficulty-calculator');
5
- var osuRebalanceDifficultyCalculator = require('@rian8337/osu-rebalance-difficulty-calculator');
6
- var unzipper = require('unzipper');
7
4
  var javaDeserialization = require('java-deserialization');
8
5
  var node_stream = require('node:stream');
6
+ var unzipper = require('unzipper');
9
7
 
10
8
  function _interopNamespaceDefault(e) {
11
9
  var n = Object.create(null);
@@ -36,442 +34,882 @@ exports.MovementType = void 0;
36
34
  /**
37
35
  * The player places their finger on the screen.
38
36
  */
39
- MovementType[MovementType["down"] = 0] = "down";
37
+ MovementType[MovementType["Down"] = 0] = "Down";
40
38
  /**
41
39
  * The player drags their finger on the screen.
42
40
  */
43
- MovementType[MovementType["move"] = 1] = "move";
41
+ MovementType[MovementType["Move"] = 1] = "Move";
44
42
  /**
45
43
  * The player releases their finger from the screen.
46
44
  */
47
- MovementType[MovementType["up"] = 2] = "up";
45
+ MovementType[MovementType["Up"] = 2] = "Up";
48
46
  })(exports.MovementType || (exports.MovementType = {}));
49
47
 
50
48
  /**
51
- * Represents a cursor's occurrence.
52
- */
53
- class CursorOccurrence {
54
- /**
55
- * The time of this occurrence.
56
- */
57
- time;
58
- /**
59
- * The position of the occurrence.
60
- */
61
- position;
62
- /**
63
- * The movement ID of the occurrence.
64
- */
65
- id;
66
- constructor(time, x, y, id) {
67
- this.time = time;
68
- this.position = new osuBase.Vector2(x, y);
69
- this.id = id;
70
- }
71
- }
72
-
73
- /**
74
- * Represents a group of cursor occurrences representing a cursor instance's
75
- * movement when a player places their finger on the screen.
49
+ * Utility to check whether relevant sliders in a beatmap are cheesed for rebalance scores.
76
50
  */
77
- class CursorOccurrenceGroup {
51
+ class RebalanceSliderCheeseChecker {
78
52
  /**
79
- * The cursor occurrence of movement type `movementType.DOWN`.
53
+ * @param beatmap The beatmap to analyze.
54
+ * @param data The data of the replay.
55
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
80
56
  */
81
- get down() {
82
- return this._down;
57
+ constructor(beatmap, data, difficultyAttributes) {
58
+ this.beatmap = beatmap;
59
+ this.data = data;
60
+ this.difficultyAttributes = difficultyAttributes;
61
+ this.hitWindow50 = difficultyAttributes.mods.has(osuBase.ModPrecise)
62
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
63
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
64
+ this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
83
65
  }
84
66
  /**
85
- * The cursor occurrence of movement type `movementType.DOWN`.
67
+ * Checks if relevant sliders in the given beatmap was cheesed.
86
68
  */
87
- set down(value) {
88
- if (value.id !== exports.MovementType.down) {
89
- throw new TypeError("Attempting to set the down cursor occurrence to one with different movement type.");
69
+ check() {
70
+ if (this.difficultyAttributes.difficultSliders.length === 0 ||
71
+ this.difficultyAttributes.sliderFactor === 1) {
72
+ return 1;
90
73
  }
91
- this._down = value;
74
+ const cheesedDifficultyRatings = this.checkSliderCheesing();
75
+ return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
92
76
  }
93
77
  /**
94
- * The cursor occurrences of movement type `movementType.MOVE`.
78
+ * Checks for sliders that were cheesed.
95
79
  */
96
- get moves() {
97
- return this._moves;
80
+ checkSliderCheesing() {
81
+ const { objects } = this.beatmap.hitObjects;
82
+ const cheesedDifficultyRatings = [];
83
+ // Current loop indices are stored for efficiency.
84
+ const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
85
+ const acceptableRadius = objects[0].radius * 2;
86
+ // Sort difficult sliders by index so that cursor loop indices work properly.
87
+ for (const difficultSlider of this.difficultyAttributes.difficultSliders
88
+ .slice()
89
+ .sort((a, b) => a.index - b.index)) {
90
+ if (difficultSlider.index >= this.data.hitObjectData.length) {
91
+ continue;
92
+ }
93
+ const object = objects[difficultSlider.index];
94
+ const objectData = this.data.hitObjectData[difficultSlider.index];
95
+ // If a miss or slider break occurs, we disregard the check for that slider.
96
+ if (objectData.result === osuBase.HitResult.Miss) {
97
+ continue;
98
+ }
99
+ let lateHitThreshold = this.hitWindow50;
100
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
101
+ if (this.data.replayVersion < 8) {
102
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
103
+ }
104
+ if (objectData.accuracy < -this.hitWindow50 ||
105
+ objectData.accuracy > lateHitThreshold) {
106
+ continue;
107
+ }
108
+ const objectStartPosition = object.stackedPosition;
109
+ // These time boundaries should consider the delta time between the previous and next
110
+ // object as well as their hit accuracy. However, they are somewhat complicated to
111
+ // compute and the accuracy gain is small. As such, let's settle with 50 hit window.
112
+ const minTimeLimit = object.startTime - this.hitWindow50;
113
+ const maxTimeLimit = object.startTime + this.hitWindow50;
114
+ // Get the closest tap distance across all cursors.
115
+ const closestDistances = [];
116
+ const closestGroupIndices = [];
117
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
118
+ const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
119
+ let closestDistance = Number.POSITIVE_INFINITY;
120
+ let closestIndex = cursorGroups.length;
121
+ for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
122
+ const group = cursorGroups[j];
123
+ if (group.endTime < minTimeLimit) {
124
+ continue;
125
+ }
126
+ if (group.startTime > maxTimeLimit) {
127
+ break;
128
+ }
129
+ if (group.startTime >= minTimeLimit) {
130
+ const position = this.getCursorPosition(group.down);
131
+ const distance = position.getDistance(objectStartPosition);
132
+ if (closestDistance > distance) {
133
+ closestDistance = distance;
134
+ closestIndex = j;
135
+ }
136
+ if (closestDistance <= acceptableRadius / 2) {
137
+ break;
138
+ }
139
+ }
140
+ // Normally, we check if there are cursor presses within the group's active time.
141
+ // However, some funky workarounds are used throughout the game for replays, so
142
+ // for the time being we only check for cursor distances across the group.
143
+ const { allOccurrences } = group;
144
+ for (let k = 1; k < allOccurrences.length; ++k) {
145
+ const cursor = allOccurrences[k];
146
+ const prevCursor = allOccurrences[k - 1];
147
+ let distance = Number.POSITIVE_INFINITY;
148
+ const currentPosition = this.getCursorPosition(cursor);
149
+ const prevPosition = this.getCursorPosition(prevCursor);
150
+ switch (cursor.id) {
151
+ case exports.MovementType.Up:
152
+ distance =
153
+ prevPosition.getDistance(objectStartPosition);
154
+ break;
155
+ case exports.MovementType.Move:
156
+ for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
157
+ Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
158
+ const t = (mSecPassed - prevCursor.time) /
159
+ (cursor.time - prevCursor.time);
160
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
161
+ distance =
162
+ cursorPosition.getDistance(objectStartPosition);
163
+ if (closestDistance > distance) {
164
+ closestDistance = distance;
165
+ closestIndex = j;
166
+ }
167
+ if (closestDistance <=
168
+ acceptableRadius / 2) {
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ if (closestDistance > distance) {
174
+ closestDistance = distance;
175
+ closestIndex = j;
176
+ }
177
+ if (closestDistance <= acceptableRadius / 2) {
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ closestDistances.push(closestDistance);
183
+ closestGroupIndices.push(closestIndex);
184
+ if (cursorLoopIndices[i] > 0) {
185
+ // Decrement the index. The previous group may also have a role on the next slider.
186
+ --cursorLoopIndices[i];
187
+ }
188
+ }
189
+ const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
190
+ const closestDistance = closestDistances[cursorIndex];
191
+ if (closestDistance > acceptableRadius / 2) {
192
+ cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
193
+ continue;
194
+ }
195
+ const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
196
+ let isCheesed = false;
197
+ // Track cursor movement to see if it lands on every tick.
198
+ let occurrenceLoopIndex = 1;
199
+ const { allOccurrences } = group;
200
+ for (let i = 1; i < object.nestedHitObjects.length; ++i) {
201
+ if (isCheesed) {
202
+ break;
203
+ }
204
+ const tickWasHit = objectData.tickset[i - 1];
205
+ if (!tickWasHit) {
206
+ continue;
207
+ }
208
+ const nestedObject = object.nestedHitObjects[i];
209
+ const nestedPosition = nestedObject.stackedPosition;
210
+ while (occurrenceLoopIndex < allOccurrences.length &&
211
+ allOccurrences[occurrenceLoopIndex].time <
212
+ nestedObject.startTime) {
213
+ ++occurrenceLoopIndex;
214
+ }
215
+ if (occurrenceLoopIndex === allOccurrences.length) {
216
+ continue;
217
+ }
218
+ const cursor = allOccurrences[occurrenceLoopIndex];
219
+ const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
220
+ const currentPosition = this.getCursorPosition(cursor);
221
+ const prevPosition = this.getCursorPosition(prevCursor);
222
+ switch (cursor.id) {
223
+ case exports.MovementType.Move: {
224
+ // Interpolate cursor position during nested object time.
225
+ const t = (nestedObject.startTime - prevCursor.time) /
226
+ (cursor.time - prevCursor.time);
227
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
228
+ const distance = cursorPosition.getDistance(nestedPosition);
229
+ isCheesed = distance > acceptableRadius;
230
+ break;
231
+ }
232
+ case exports.MovementType.Up:
233
+ isCheesed =
234
+ prevPosition.getDistance(nestedPosition) >
235
+ acceptableRadius;
236
+ }
237
+ }
238
+ if (isCheesed) {
239
+ cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
240
+ }
241
+ }
242
+ return cheesedDifficultyRatings;
98
243
  }
99
244
  /**
100
- * The cursor occurrence of movement type `movementType.UP`.
101
- *
102
- * May not exist, such as when the player holds their cursor until the end of a beatmap.
245
+ * Calculates the slider cheese penalty.
103
246
  */
104
- get up() {
105
- return this._up;
247
+ calculateSliderCheesePenalty(cheesedDifficultyRatings) {
248
+ const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
249
+ return Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
250
+ summedDifficultyRating *
251
+ this.difficultyAttributes.sliderFactor, 2));
106
252
  }
107
- /**
108
- * The cursor occurrence of movement type `movementType.UP`.
109
- *
110
- * May not exist, such as when the player holds their cursor until the end of a beatmap.
111
- */
112
- set up(value) {
113
- if (value && value.id !== exports.MovementType.up) {
114
- throw new TypeError("Attempting to set the up cursor occurrence to one with different movement type.");
253
+ getCursorPosition(cursor) {
254
+ if (this.isHardRock) {
255
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
115
256
  }
116
- this._up = value;
257
+ return cursor.position;
117
258
  }
259
+ }
260
+
261
+ /**
262
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
263
+ */
264
+ class RebalanceThreeFingerChecker {
118
265
  /**
119
- * The time at which this cursor occurrence group starts.
266
+ * @param beatmap The beatmap to analyze.
267
+ * @param data The data of the replay.
268
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
120
269
  */
121
- get startTime() {
122
- return this._down.time;
270
+ constructor(beatmap, data, difficultyAttributes) {
271
+ /**
272
+ * Extended sections of the beatmap for drag detection.
273
+ */
274
+ this.beatmapSections = [];
275
+ /**
276
+ * A reprocessed break points to match right on object time.
277
+ *
278
+ * This is used to increase detection accuracy since break points do not start right at the
279
+ * start of the hitobject before it and do not end right at the first hitobject after it.
280
+ */
281
+ this.breakPointAccurateTimes = [];
282
+ /**
283
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
284
+ *
285
+ * Each index represents the cursor index.
286
+ */
287
+ this.downCursorInstances = [];
288
+ /**
289
+ * Nerf factors from all sections that were three-fingered.
290
+ */
291
+ this.nerfFactors = [];
292
+ this.beatmap = beatmap;
293
+ this.data = data;
294
+ this.difficultyAttributes = difficultyAttributes;
295
+ this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
296
+ this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
297
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
298
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od);
123
299
  }
124
300
  /**
125
- * The time at which this cursor occurrence group ends.
301
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
302
+ *
303
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
126
304
  */
127
- get endTime() {
128
- return this._up?.time ?? this._moves.at(-1)?.time ?? this._down.time;
305
+ static isEligibleToDetect(difficultyAttributes) {
306
+ return difficultyAttributes.possibleThreeFingeredSections.length > 0;
129
307
  }
130
308
  /**
131
- * The duration this cursor occurrence group is active for.
309
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
310
+ *
311
+ * The beatmap will be separated into sections and each section will be determined
312
+ * whether or not it is dragged.
313
+ *
314
+ * After that, each section will be assigned a nerf factor based on whether or not
315
+ * the section is 3-fingered. These nerf factors will be summed up into a final
316
+ * nerf factor, taking beatmap difficulty into account.
132
317
  */
133
- get duration() {
134
- return this.endTime - this.startTime;
318
+ check() {
319
+ if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
320
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
321
+ return { is3Finger: false, penalty: 1 };
322
+ }
323
+ this.getAccurateBreakPoints();
324
+ this.filterCursorInstances();
325
+ this.getBeatmapSections();
326
+ this.calculateNerfFactors();
327
+ const finalPenalty = this.calculateFinalPenalty();
328
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
135
329
  }
136
330
  /**
137
- * All cursor occurrences in this group.
331
+ * Generates a new set of "accurate break points".
138
332
  *
139
- * This iterates all occurrences and as such should be used sparingly or stored locally.
333
+ * This is done to increase detection accuracy since break points do not start right at the
334
+ * end of the hitobject before it and do not end right at the first hitobject after it.
140
335
  */
141
- get allOccurrences() {
142
- const cursors = [this._down, ...this._moves];
143
- if (this._up) {
144
- cursors.push(this._up);
336
+ getAccurateBreakPoints() {
337
+ const objects = this.beatmap.hitObjects.objects;
338
+ const objectData = this.data.hitObjectData;
339
+ for (const breakPoint of this.beatmap.events.breaks) {
340
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
341
+ const objectBefore = objects[beforeIndex];
342
+ const objectBeforeData = objectData[beforeIndex];
343
+ let timeBefore = objectBefore.endTime;
344
+ if (objectBefore instanceof osuBase.Circle) {
345
+ if (objectBeforeData.result !== osuBase.HitResult.Miss) {
346
+ timeBefore += objectBeforeData.accuracy;
347
+ }
348
+ else {
349
+ timeBefore += this.hitWindow.mehWindow;
350
+ }
351
+ }
352
+ const afterIndex = beforeIndex + 1;
353
+ const objectAfter = objects[afterIndex];
354
+ const objectAfterData = objectData[afterIndex];
355
+ let timeAfter = objectAfter.startTime;
356
+ if (objectAfter instanceof osuBase.Circle &&
357
+ objectAfterData.result !== osuBase.HitResult.Miss) {
358
+ timeAfter += objectAfterData.accuracy;
359
+ }
360
+ this.breakPointAccurateTimes.push(new osuBase.BreakPoint(timeBefore, timeAfter));
145
361
  }
146
- return cursors;
147
362
  }
148
363
  /**
149
- * The cursor occurrence of movement type `movementType.DOWN`.
150
- */
151
- _down;
152
- /**
153
- * The cursor occurrences of movement type `movementType.MOVE`.
154
- */
155
- _moves;
156
- /**
157
- * The cursor occurrence of movement type `movementType.UP`.
364
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
158
365
  *
159
- * May not exist, such as when the player holds their cursor until the end of a beatmap.
366
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
160
367
  */
161
- _up;
162
- constructor(down, moves, up) {
163
- this._down = down;
164
- this._moves = moves;
165
- // Re-set down cursor occurrence for checking.
166
- this.down = down;
167
- this.up = up;
168
- }
169
- /**
170
- * Determines whether this cursor occurrence group is active at the specified time.
171
- *
172
- * @param time The time.
173
- * @returns Whether this cursor occurrence group is active at the specified time.
174
- */
175
- isActiveAt(time) {
176
- return time >= this.startTime && time <= this.endTime;
177
- }
178
- /**
179
- * Finds the cursor occurrence that is active at a given time.
180
- *
181
- * @param time The time.
182
- * @returns The cursor occurrence at the given time, `null` if not found.
183
- */
184
- cursorAt(time) {
185
- if (!this.isActiveAt(time)) {
186
- return null;
368
+ filterCursorInstances() {
369
+ const objects = this.beatmap.hitObjects.objects;
370
+ const objectData = this.data.hitObjectData;
371
+ const firstObjectResult = objectData[0].result;
372
+ const lastObjectResult = objectData.at(-1).result;
373
+ const firstObject = objects[0];
374
+ const lastObject = objects.at(-1);
375
+ // For sliders, automatically set hit window length to be as lenient as possible.
376
+ let firstObjectHitWindow = this.hitWindow.mehWindow;
377
+ if (firstObject instanceof osuBase.Circle) {
378
+ switch (firstObjectResult) {
379
+ case osuBase.HitResult.Great:
380
+ firstObjectHitWindow = this.hitWindow.greatWindow;
381
+ break;
382
+ case osuBase.HitResult.Good:
383
+ firstObjectHitWindow = this.hitWindow.okWindow;
384
+ break;
385
+ default:
386
+ firstObjectHitWindow = this.hitWindow.mehWindow;
387
+ }
187
388
  }
188
- if (this._down.time === time) {
189
- return this._down;
389
+ // For sliders, automatically set hit window length to be as lenient as possible.
390
+ let lastObjectHitWindow = this.hitWindow.mehWindow;
391
+ if (lastObject instanceof osuBase.Circle) {
392
+ switch (lastObjectResult) {
393
+ case osuBase.HitResult.Great:
394
+ lastObjectHitWindow = this.hitWindow.greatWindow;
395
+ break;
396
+ case osuBase.HitResult.Good:
397
+ lastObjectHitWindow = this.hitWindow.okWindow;
398
+ break;
399
+ default:
400
+ lastObjectHitWindow = this.hitWindow.mehWindow;
401
+ }
190
402
  }
191
- if (this._up?.time === time) {
192
- return this._up;
403
+ else if (lastObject instanceof osuBase.Slider) {
404
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
193
405
  }
194
- let l = 0;
195
- let r = this._moves.length - 2;
196
- while (l <= r) {
197
- const pivot = l + ((r - l) >> 1);
198
- if (this._moves[pivot].time < time) {
199
- l = pivot + 1;
200
- }
201
- else if (this._moves[pivot].time > time) {
202
- r = pivot - 1;
203
- }
204
- else {
205
- return this._moves[pivot];
406
+ // These hit time uses hit window length as threshold.
407
+ // This is because cursors aren't recorded exactly at hit time,
408
+ // probably due to the game's behavior.
409
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
410
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
411
+ for (const cursorInstance of this.data.cursorMovement) {
412
+ const validOccurrences = [];
413
+ for (const group of cursorInstance.occurrenceGroups) {
414
+ if (group.startTime < firstObjectHitTime) {
415
+ continue;
416
+ }
417
+ if (group.startTime > lastObjectHitTime) {
418
+ break;
419
+ }
420
+ if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
421
+ group.endTime <= v.endTime)) {
422
+ continue;
423
+ }
424
+ validOccurrences.push(group.down);
206
425
  }
426
+ this.downCursorInstances.push(validOccurrences);
207
427
  }
208
- // l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
209
- return this._moves[l - 1];
210
428
  }
211
- }
212
-
213
- /**
214
- * Represents a cursor instance in an osu!droid replay.
215
- *
216
- * Stores cursor movement data in the form of `CursorOccurrenceGroup`s.
217
- *
218
- * This is used when analyzing replays using replay analyzer.
219
- */
220
- class CursorData {
221
- /**
222
- * The occurrence groups of this cursor instance.
223
- */
224
- occurrenceGroups = [];
225
429
  /**
226
- * The time at which the first occurrence of this cursor instance occurs.
227
- *
228
- * Will return `null` if there are no occurrences.
430
+ * Divides the beatmap into sections, which will be used to
431
+ * detect dragged sections and improve detection speed.
229
432
  */
230
- get earliestOccurrenceTime() {
231
- return this.occurrenceGroups.at(0)?.startTime ?? null;
433
+ getBeatmapSections() {
434
+ const beatmapObjects = this.beatmap.hitObjects.objects;
435
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
436
+ // This intentionally starts from 1 because we need to look at the previous cursor.
437
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
438
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
439
+ for (const section of this.difficultyAttributes
440
+ .possibleThreeFingeredSections) {
441
+ const objects = [];
442
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
443
+ const object = beatmapObjects[i];
444
+ const objectData = this.data.hitObjectData[i];
445
+ objects.push({
446
+ object: object,
447
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
448
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
449
+ });
450
+ }
451
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
452
+ }
232
453
  }
233
454
  /**
234
- * The time at which the latest occurrence of this cursor instance occurs.
455
+ * Obtains the index of the cursor that aimed the object at the nearest time.
235
456
  *
236
- * Will return `null` if there are no occurrences.
237
- */
238
- get latestOccurrenceTime() {
239
- return this.occurrenceGroups.at(-1)?.endTime ?? null;
240
- }
241
- /**
242
- * The amount of cursor occurrences of this cursor instance.
243
- */
244
- get totalOccurrences() {
245
- return this.occurrenceGroups.reduce((a, v) => {
246
- // Down cursor.
247
- ++a;
248
- // Move cursors.
249
- a += v.moves.length;
250
- if (v.up) {
251
- // Up cursor.
252
- ++a;
457
+ * @param object The object to obtain the index for.
458
+ * @param objectData The hit data of the object.
459
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
460
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
461
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
462
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
463
+ */
464
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
465
+ if (objectData.result === osuBase.HitResult.Miss || object instanceof osuBase.Spinner) {
466
+ return -1;
467
+ }
468
+ // Check for sliderbreaks and treat them as misses.
469
+ if (object instanceof osuBase.Slider) {
470
+ let lateHitThreshold = this.hitWindow.mehWindow;
471
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
472
+ if (this.data.replayVersion < 8) {
473
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
253
474
  }
254
- return a;
255
- }, 0);
475
+ if (objectData.accuracy < -this.hitWindow.mehWindow ||
476
+ objectData.accuracy > lateHitThreshold) {
477
+ return -1;
478
+ }
479
+ }
480
+ const hitTime = object.startTime + objectData.accuracy;
481
+ const objectPosition = object.stackedPosition;
482
+ // We are maintaining the closest distance to the object.
483
+ // This is because the radius that is calculated is using an estimation.
484
+ // As such, it does not reflect the actual object radius in gameplay.
485
+ let closestDistance = Number.POSITIVE_INFINITY;
486
+ let nearestCursorIndex = -1;
487
+ // Observe the cursor position at the object's hit time.
488
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
489
+ const cursorData = this.data.cursorMovement[i];
490
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
491
+ const cursorGroup = cursorData.occurrenceGroups[j];
492
+ if (cursorGroup.endTime < hitTime) {
493
+ // Reset cursor index pointer.
494
+ cursorIndices[i] = 1;
495
+ continue;
496
+ }
497
+ if (cursorGroup.startTime > hitTime) {
498
+ break;
499
+ }
500
+ const cursors = cursorGroup.allOccurrences;
501
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
502
+ const cursor = cursors[k];
503
+ const prevCursor = cursors[k - 1];
504
+ // Cursor is past the object's hit time.
505
+ if (prevCursor.time > hitTime) {
506
+ break;
507
+ }
508
+ // Cursor is before the object's hit time.
509
+ if (hitTime > cursor.time) {
510
+ continue;
511
+ }
512
+ let distance;
513
+ const currentPosition = this.getCursorPosition(cursor);
514
+ const prevPosition = this.getCursorPosition(prevCursor);
515
+ switch (cursor.id) {
516
+ case exports.MovementType.Up:
517
+ distance = prevPosition.getDistance(objectPosition);
518
+ break;
519
+ case exports.MovementType.Move: {
520
+ // Interpolate movement.
521
+ const t = (hitTime - prevCursor.time) /
522
+ (cursor.time - prevCursor.time);
523
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
524
+ distance =
525
+ objectPosition.getDistance(cursorPosition);
526
+ break;
527
+ }
528
+ case exports.MovementType.Down:
529
+ continue;
530
+ }
531
+ if (closestDistance > distance) {
532
+ closestDistance = distance;
533
+ nearestCursorIndex = i;
534
+ }
535
+ }
536
+ // Reset cursor index pointer on end of group.
537
+ if (cursorIndices[i] === cursors.length) {
538
+ cursorIndices[i] = 1;
539
+ }
540
+ break;
541
+ }
542
+ // The previous object may still be hit with the same cursor group or cursor index.
543
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
544
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
545
+ }
546
+ return nearestCursorIndex;
256
547
  }
257
548
  /**
258
- * All cursor occurrences of this cursor instnace.
549
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
259
550
  *
260
- * This iterates all occurrence groups and as such should be used sparingly or stored locally.
261
- */
262
- get allOccurrences() {
263
- return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
264
- }
265
- constructor(values) {
266
- let downOccurrence = null;
267
- let moveOccurrences = [];
268
- for (let i = 0; i < values.size; ++i) {
269
- const occurrence = new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]);
270
- switch (occurrence.id) {
271
- case exports.MovementType.down:
272
- downOccurrence = occurrence;
273
- break;
274
- case exports.MovementType.move:
275
- moveOccurrences.push(occurrence);
276
- break;
277
- case exports.MovementType.up:
278
- if (downOccurrence) {
279
- this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences, occurrence));
280
- downOccurrence = null;
281
- }
282
- moveOccurrences = [];
551
+ * @param object The object to obtain the index for.
552
+ * @param objectData The hit data of the object.
553
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
554
+ * @param excludedIndices The cursor indices that should not be checked.
555
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
556
+ */
557
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
558
+ if (objectData.result === osuBase.HitResult.Miss || object instanceof osuBase.Spinner) {
559
+ return -1;
560
+ }
561
+ // Check for sliderbreaks and treat them as misses.
562
+ if (object instanceof osuBase.Slider) {
563
+ let lateHitThreshold = this.hitWindow.mehWindow;
564
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
565
+ if (this.data.replayVersion < 8) {
566
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
567
+ }
568
+ if (objectData.accuracy < -this.hitWindow.mehWindow ||
569
+ objectData.accuracy > lateHitThreshold) {
570
+ return -1;
283
571
  }
284
572
  }
285
- // Add the final cursor occurrence group as the loop may not catch it for special cases.
286
- if (downOccurrence && moveOccurrences.length > 0) {
287
- this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences));
573
+ const hitTime = object.startTime + objectData.accuracy;
574
+ let nearestCursorInstanceIndex = -1;
575
+ let nearestTime = Number.POSITIVE_INFINITY;
576
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
577
+ const cursors = this.downCursorInstances[i];
578
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
579
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
580
+ const cursor = cursors[j];
581
+ if (cursor.time > hitTime) {
582
+ break;
583
+ }
584
+ cursorNearestTime = hitTime - cursor.time;
585
+ }
586
+ if (cursorNearestTime < nearestTime) {
587
+ nearestCursorInstanceIndex = i;
588
+ nearestTime = cursorNearestTime;
589
+ }
288
590
  }
591
+ return nearestCursorInstanceIndex;
289
592
  }
290
- }
291
-
292
- /**
293
- * The result of a hit in an osu!droid replay.
294
- */
295
- exports.HitResult = void 0;
296
- (function (HitResult) {
297
- /**
298
- * Miss (0).
299
- */
300
- HitResult[HitResult["miss"] = 1] = "miss";
301
- /**
302
- * Meh (50).
303
- */
304
- HitResult[HitResult["meh"] = 2] = "meh";
305
593
  /**
306
- * Good (100).
594
+ * Creates nerf factors by scanning through objects.
307
595
  */
308
- HitResult[HitResult["good"] = 3] = "good";
596
+ calculateNerfFactors() {
597
+ for (const beatmapSection of this.beatmapSections) {
598
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
599
+ for (const object of beatmapSection.objects) {
600
+ if (object.pressingCursorInstanceIndex === -1) {
601
+ continue;
602
+ }
603
+ if (object.aimingCursorInstanceIndex < 3) {
604
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
605
+ switch (object.pressingCursorInstanceIndex) {
606
+ case 0:
607
+ case 1:
608
+ case 2:
609
+ break;
610
+ default:
611
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
612
+ break;
613
+ }
614
+ }
615
+ else {
616
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
617
+ switch (object.pressingCursorInstanceIndex) {
618
+ case 0:
619
+ case 1:
620
+ break;
621
+ default:
622
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
628
+ if (threeFingerCursorCount === 0) {
629
+ continue;
630
+ }
631
+ const sectionObjectCount = beatmapSection.objects.length;
632
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
633
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
634
+ // Finger factor applies more penalty if more fingers were used.
635
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
636
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
637
+ // Length factor applies more penalty if there are more 3-fingered object.
638
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
639
+ this.nerfFactors.push({
640
+ strainFactor: strainFactor,
641
+ fingerFactor: fingerFactor,
642
+ lengthFactor: lengthFactor,
643
+ });
644
+ }
645
+ }
309
646
  /**
310
- * Great (300).
647
+ * Calculates the final penalty.
311
648
  */
312
- HitResult[HitResult["great"] = 4] = "great";
313
- })(exports.HitResult || (exports.HitResult = {}));
314
-
315
- /**
316
- * Represents a replay data in an osu!droid replay.
317
- *
318
- * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
319
- *
320
- * This is used when analyzing replays using replay analyzer.
321
- */
322
- class ReplayData {
323
- replayVersion;
324
- folderName;
325
- fileName;
326
- hash;
327
- time;
328
- hit300k;
329
- hit100k;
330
- score;
331
- maxCombo;
332
- accuracy;
333
- isFullCombo;
334
- playerName;
335
- rawMods;
336
- rank;
337
- convertedMods;
338
- cursorMovement;
339
- hitObjectData;
340
- speedModification;
341
- forcedAR;
342
- constructor(values) {
343
- this.replayVersion = values.replayVersion;
344
- this.folderName = values.folderName;
345
- this.fileName = values.fileName;
346
- this.hash = values.hash;
347
- this.time = new Date(values.time || 0);
348
- this.hit300k = values.hit300k || 0;
349
- this.hit100k = values.hit100k || 0;
350
- this.score = values.score || 0;
351
- this.maxCombo = values.maxCombo || 0;
352
- this.accuracy = values.accuracy || new osuBase.Accuracy({});
353
- this.isFullCombo = values.isFullCombo || false;
354
- this.playerName = values.playerName || "";
355
- this.rawMods = values.rawMods || "";
356
- this.rank = values.rank || "";
357
- this.convertedMods = values.convertedMods || [];
358
- this.cursorMovement = values.cursorMovement;
359
- this.hitObjectData = values.hitObjectData;
360
- this.speedModification = values.speedModification || 1;
361
- this.forcedAR = values.forcedAR;
649
+ calculateFinalPenalty() {
650
+ return this.nerfFactors.reduce((a, n) => a +
651
+ 0.015 *
652
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
653
+ }
654
+ getCursorPosition(cursor) {
655
+ if (this.isHardRock) {
656
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
657
+ }
658
+ return cursor.position;
362
659
  }
363
660
  }
364
661
 
365
662
  /**
366
- * Utility to check whether or not a beatmap is three-fingered.
663
+ * Utility to check whether relevant sliders in a beatmap are cheesed for live scores.
367
664
  */
368
- class ThreeFingerChecker {
369
- /**
370
- * The beatmap that is being analyzed.
371
- */
372
- beatmap;
373
- /**
374
- * The data of the replay.
375
- */
376
- data;
377
- /**
378
- * The difficulty attributes of the beatmap.
379
- */
380
- difficultyAttributes;
381
- /**
382
- * The true scale of objects.
383
- */
384
- trueScale;
385
- /**
386
- * The distance threshold between cursors to assume that two cursors are
387
- * actually pressed with 1 finger in osu!pixels.
388
- *
389
- * This is used to prevent cases where a player would lift their finger
390
- * too fast to the point where the 4th cursor instance or beyond is recorded
391
- * as 1st, 2nd, or 3rd cursor instance.
392
- */
393
- cursorDistancingDistanceThreshold = 60;
665
+ class SliderCheeseChecker {
394
666
  /**
395
- * The threshold for the amount of cursors that are assumed to be pressed
396
- * by a single finger.
667
+ * @param beatmap The beatmap to analyze.
668
+ * @param data The data of the replay.
669
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
397
670
  */
398
- cursorDistancingCountThreshold = 10;
671
+ constructor(beatmap, data, difficultyAttributes) {
672
+ this.beatmap = beatmap;
673
+ this.data = data;
674
+ this.difficultyAttributes = difficultyAttributes;
675
+ this.hitWindow50 = difficultyAttributes.mods.has(osuBase.ModPrecise)
676
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
677
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
678
+ this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
679
+ }
399
680
  /**
400
- * The threshold for the time difference of cursors that are assumed to be pressed
401
- * by a single finger, in milliseconds.
681
+ * Checks if relevant sliders in the given beatmap was cheesed.
402
682
  */
403
- cursorDistancingTimeThreshold = 1000;
683
+ check() {
684
+ if (this.difficultyAttributes.difficultSliders.length === 0 ||
685
+ this.difficultyAttributes.sliderFactor === 1) {
686
+ return 1;
687
+ }
688
+ const cheesedDifficultyRatings = this.checkSliderCheesing();
689
+ return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
690
+ }
404
691
  /**
405
- * The amount of notes that has a tap strain exceeding `strainThreshold`.
692
+ * Checks for sliders that were cheesed.
406
693
  */
407
- strainNoteCount;
408
- /**
409
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
410
- *
411
- * Increasing this number will increase detection accuracy, however
412
- * it also increases the chance of falsely flagged plays.
413
- */
414
- threeFingerRatioThreshold = 0.01;
415
- /**
416
- * Extended sections of the beatmap for drag detection.
417
- */
418
- beatmapSections = [];
419
- /**
420
- * This threshold is used to filter out accidental taps.
421
- *
422
- * Increasing this number makes the filtration more sensitive, however it
423
- * will also increase the chance of 3-fingered plays getting out from
424
- * being flagged.
425
- */
426
- accidentalTapThreshold = 400;
427
- /**
428
- * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
429
- */
430
- hitWindow;
431
- /**
432
- * A reprocessed break points to match right on object time.
433
- *
434
- * This is used to increase detection accuracy since break points do not start right at the
435
- * start of the hitobject before it and do not end right at the first hitobject after it.
436
- */
437
- breakPointAccurateTimes = [];
438
- /**
439
- * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
440
- *
441
- * Each index represents the cursor index.
442
- */
443
- downCursorInstances = [];
444
- /**
445
- * Nerf factors from all sections that were three-fingered.
446
- */
447
- nerfFactors = [];
694
+ checkSliderCheesing() {
695
+ const { objects } = this.beatmap.hitObjects;
696
+ const cheesedDifficultyRatings = [];
697
+ // Current loop indices are stored for efficiency.
698
+ const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
699
+ const acceptableRadius = objects[0].radius * 2;
700
+ // Sort difficult sliders by index so that cursor loop indices work properly.
701
+ for (const difficultSlider of this.difficultyAttributes.difficultSliders
702
+ .slice()
703
+ .sort((a, b) => a.index - b.index)) {
704
+ if (difficultSlider.index >= this.data.hitObjectData.length) {
705
+ continue;
706
+ }
707
+ const object = objects[difficultSlider.index];
708
+ const objectData = this.data.hitObjectData[difficultSlider.index];
709
+ // If a miss or slider break occurs, we disregard the check for that slider.
710
+ if (objectData.result === osuBase.HitResult.Miss) {
711
+ continue;
712
+ }
713
+ let lateHitThreshold = this.hitWindow50;
714
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
715
+ if (this.data.replayVersion < 8) {
716
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
717
+ }
718
+ if (objectData.accuracy < -this.hitWindow50 ||
719
+ objectData.accuracy > lateHitThreshold) {
720
+ continue;
721
+ }
722
+ const objectStartPosition = object.stackedPosition;
723
+ // These time boundaries should consider the delta time between the previous and next
724
+ // object as well as their hit accuracy. However, they are somewhat complicated to
725
+ // compute and the accuracy gain is small. As such, let's settle with 50 hit window.
726
+ const minTimeLimit = object.startTime - this.hitWindow50;
727
+ const maxTimeLimit = object.startTime + this.hitWindow50;
728
+ // Get the closest tap distance across all cursors.
729
+ const closestDistances = [];
730
+ const closestGroupIndices = [];
731
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
732
+ const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
733
+ let closestDistance = Number.POSITIVE_INFINITY;
734
+ let closestIndex = cursorGroups.length;
735
+ for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
736
+ const group = cursorGroups[j];
737
+ if (group.endTime < minTimeLimit) {
738
+ continue;
739
+ }
740
+ if (group.startTime > maxTimeLimit) {
741
+ break;
742
+ }
743
+ if (group.startTime >= minTimeLimit) {
744
+ const position = this.getCursorPosition(group.down);
745
+ const distance = position.getDistance(objectStartPosition);
746
+ if (closestDistance > distance) {
747
+ closestDistance = distance;
748
+ closestIndex = j;
749
+ }
750
+ if (closestDistance <= acceptableRadius / 2) {
751
+ break;
752
+ }
753
+ }
754
+ // Normally, we check if there are cursor presses within the group's active time.
755
+ // However, some funky workarounds are used throughout the game for replays, so
756
+ // for the time being we only check for cursor distances across the group.
757
+ const { allOccurrences } = group;
758
+ for (let k = 1; k < allOccurrences.length; ++k) {
759
+ const cursor = allOccurrences[k];
760
+ const prevCursor = allOccurrences[k - 1];
761
+ let distance = Number.POSITIVE_INFINITY;
762
+ const currentPosition = this.getCursorPosition(cursor);
763
+ const prevPosition = this.getCursorPosition(prevCursor);
764
+ switch (cursor.id) {
765
+ case exports.MovementType.Up:
766
+ distance =
767
+ prevPosition.getDistance(objectStartPosition);
768
+ break;
769
+ case exports.MovementType.Move:
770
+ for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
771
+ Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
772
+ const t = (mSecPassed - prevCursor.time) /
773
+ (cursor.time - prevCursor.time);
774
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
775
+ distance =
776
+ cursorPosition.getDistance(objectStartPosition);
777
+ if (closestDistance > distance) {
778
+ closestDistance = distance;
779
+ closestIndex = j;
780
+ }
781
+ if (closestDistance <=
782
+ acceptableRadius / 2) {
783
+ break;
784
+ }
785
+ }
786
+ }
787
+ if (closestDistance > distance) {
788
+ closestDistance = distance;
789
+ closestIndex = j;
790
+ }
791
+ if (closestDistance <= acceptableRadius / 2) {
792
+ break;
793
+ }
794
+ }
795
+ }
796
+ closestDistances.push(closestDistance);
797
+ closestGroupIndices.push(closestIndex);
798
+ if (cursorLoopIndices[i] > 0) {
799
+ // Decrement the index. The previous group may also have a role on the next slider.
800
+ --cursorLoopIndices[i];
801
+ }
802
+ }
803
+ const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
804
+ const closestDistance = closestDistances[cursorIndex];
805
+ if (closestDistance > acceptableRadius / 2) {
806
+ cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
807
+ continue;
808
+ }
809
+ const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
810
+ let isCheesed = false;
811
+ // Track cursor movement to see if it lands on every tick.
812
+ let occurrenceLoopIndex = 1;
813
+ const { allOccurrences } = group;
814
+ for (let i = 1; i < object.nestedHitObjects.length; ++i) {
815
+ if (isCheesed) {
816
+ break;
817
+ }
818
+ const tickWasHit = objectData.tickset[i - 1];
819
+ if (!tickWasHit) {
820
+ continue;
821
+ }
822
+ const nestedObject = object.nestedHitObjects[i];
823
+ const nestedPosition = nestedObject.stackedPosition;
824
+ while (occurrenceLoopIndex < allOccurrences.length &&
825
+ allOccurrences[occurrenceLoopIndex].time <
826
+ nestedObject.startTime) {
827
+ ++occurrenceLoopIndex;
828
+ }
829
+ if (occurrenceLoopIndex === allOccurrences.length) {
830
+ continue;
831
+ }
832
+ const cursor = allOccurrences[occurrenceLoopIndex];
833
+ const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
834
+ const currentPosition = this.getCursorPosition(cursor);
835
+ const prevPosition = this.getCursorPosition(prevCursor);
836
+ switch (cursor.id) {
837
+ case exports.MovementType.Move: {
838
+ // Interpolate cursor position during nested object time.
839
+ const t = (nestedObject.startTime - prevCursor.time) /
840
+ (cursor.time - prevCursor.time);
841
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
842
+ const distance = cursorPosition.getDistance(nestedPosition);
843
+ isCheesed = distance > acceptableRadius;
844
+ break;
845
+ }
846
+ case exports.MovementType.Up:
847
+ isCheesed =
848
+ prevPosition.getDistance(nestedPosition) >
849
+ acceptableRadius;
850
+ }
851
+ }
852
+ if (isCheesed) {
853
+ cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
854
+ }
855
+ }
856
+ return cheesedDifficultyRatings;
857
+ }
448
858
  /**
449
- * Whether this score uses the Precise mod.
859
+ * Calculates the slider cheese penalty.
450
860
  */
451
- isPrecise;
861
+ calculateSliderCheesePenalty(cheesedDifficultyRatings) {
862
+ const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
863
+ return Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
864
+ summedDifficultyRating *
865
+ this.difficultyAttributes.sliderFactor, 2));
866
+ }
867
+ getCursorPosition(cursor) {
868
+ if (this.isHardRock) {
869
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
870
+ }
871
+ return cursor.position;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Utility to check whether or not a beatmap is three-fingered for live scores.
877
+ */
878
+ class ThreeFingerChecker {
452
879
  /**
453
880
  * @param beatmap The beatmap to analyze.
454
881
  * @param data The data of the replay.
455
882
  * @param difficultyAttributes The difficulty attributes of the beatmap.
456
883
  */
457
884
  constructor(beatmap, data, difficultyAttributes) {
885
+ /**
886
+ * Extended sections of the beatmap for drag detection.
887
+ */
888
+ this.beatmapSections = [];
889
+ /**
890
+ * A reprocessed break points to match right on object time.
891
+ *
892
+ * This is used to increase detection accuracy since break points do not start right at the
893
+ * start of the hitobject before it and do not end right at the first hitobject after it.
894
+ */
895
+ this.breakPointAccurateTimes = [];
896
+ /**
897
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
898
+ *
899
+ * Each index represents the cursor index.
900
+ */
901
+ this.downCursorInstances = [];
902
+ /**
903
+ * Nerf factors from all sections that were three-fingered.
904
+ */
905
+ this.nerfFactors = [];
458
906
  this.beatmap = beatmap;
459
907
  this.data = data;
460
908
  this.difficultyAttributes = difficultyAttributes;
461
- const stats = new osuBase.MapStats({
462
- od: this.beatmap.difficulty.od,
463
- mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
464
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
465
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
466
- this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
467
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
468
- this.strainNoteCount =
469
- this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
470
- const circleSize = new osuBase.MapStats({
471
- cs: this.beatmap.difficulty.cs,
472
- mods: this.difficultyAttributes.mods,
473
- }).calculate({ mode: osuBase.Modes.droid }).cs;
474
- this.trueScale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
909
+ this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
910
+ this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
911
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
912
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od);
475
913
  }
476
914
  /**
477
915
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -492,20 +930,13 @@ class ThreeFingerChecker {
492
930
  * nerf factor, taking beatmap difficulty into account.
493
931
  */
494
932
  check() {
495
- if (this.strainNoteCount === 0) {
933
+ if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
934
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
496
935
  return { is3Finger: false, penalty: 1 };
497
936
  }
498
937
  this.getAccurateBreakPoints();
499
938
  this.filterCursorInstances();
500
- if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
501
- return { is3Finger: false, penalty: 1 };
502
- }
503
939
  this.getBeatmapSections();
504
- this.detectDragPlay();
505
- this.preventAccidentalTaps();
506
- if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
507
- return { is3Finger: false, penalty: 1 };
508
- }
509
940
  this.calculateNerfFactors();
510
941
  const finalPenalty = this.calculateFinalPenalty();
511
942
  return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
@@ -514,46 +945,33 @@ class ThreeFingerChecker {
514
945
  * Generates a new set of "accurate break points".
515
946
  *
516
947
  * This is done to increase detection accuracy since break points do not start right at the
517
- * start of the hitobject before it and do not end right at the first hitobject after it.
948
+ * end of the hitobject before it and do not end right at the first hitobject after it.
518
949
  */
519
950
  getAccurateBreakPoints() {
520
- const { objects } = this.beatmap.hitObjects;
951
+ const objects = this.beatmap.hitObjects.objects;
521
952
  const objectData = this.data.hitObjectData;
522
953
  for (const breakPoint of this.beatmap.events.breaks) {
523
954
  const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
524
- let timeBefore = objects[beforeIndex].endTime;
525
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
526
- let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
527
- switch (objectData[beforeIndex].result) {
528
- case exports.HitResult.great:
529
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor300(this.isPrecise);
530
- break;
531
- case exports.HitResult.good:
532
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
533
- break;
534
- default:
535
- beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
955
+ const objectBefore = objects[beforeIndex];
956
+ const objectBeforeData = objectData[beforeIndex];
957
+ let timeBefore = objectBefore.endTime;
958
+ if (objectBefore instanceof osuBase.Circle) {
959
+ if (objectBeforeData.result !== osuBase.HitResult.Miss) {
960
+ timeBefore += objectBeforeData.accuracy;
961
+ }
962
+ else {
963
+ timeBefore += this.hitWindow.mehWindow;
964
+ }
536
965
  }
537
- timeBefore += beforeIndexHitWindowLength;
538
966
  const afterIndex = beforeIndex + 1;
539
- let timeAfter = objects[afterIndex].startTime;
540
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
541
- let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
542
- switch (objectData[afterIndex].result) {
543
- case exports.HitResult.great:
544
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor300(this.isPrecise);
545
- break;
546
- case exports.HitResult.good:
547
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
548
- break;
549
- default:
550
- afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
967
+ const objectAfter = objects[afterIndex];
968
+ const objectAfterData = objectData[afterIndex];
969
+ let timeAfter = objectAfter.startTime;
970
+ if (objectAfter instanceof osuBase.Circle &&
971
+ objectAfterData.result !== osuBase.HitResult.Miss) {
972
+ timeAfter += objectAfterData.accuracy;
551
973
  }
552
- timeAfter += afterIndexHitWindowLength;
553
- this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
554
- startTime: timeBefore,
555
- endTime: timeAfter,
556
- }));
974
+ this.breakPointAccurateTimes.push(new osuBase.BreakPoint(timeBefore, timeAfter));
557
975
  }
558
976
  }
559
977
  /**
@@ -562,48 +980,51 @@ class ThreeFingerChecker {
562
980
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
563
981
  */
564
982
  filterCursorInstances() {
565
- const { objects } = this.beatmap.hitObjects;
983
+ const objects = this.beatmap.hitObjects.objects;
566
984
  const objectData = this.data.hitObjectData;
567
985
  const firstObjectResult = objectData[0].result;
568
986
  const lastObjectResult = objectData.at(-1).result;
987
+ const firstObject = objects[0];
988
+ const lastObject = objects.at(-1);
569
989
  // For sliders, automatically set hit window length to be as lenient as possible.
570
- let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
571
- if (objects[0] instanceof osuBase.Circle) {
990
+ let firstObjectHitWindow = this.hitWindow.mehWindow;
991
+ if (firstObject instanceof osuBase.Circle) {
572
992
  switch (firstObjectResult) {
573
- case exports.HitResult.great:
574
- firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
993
+ case osuBase.HitResult.Great:
994
+ firstObjectHitWindow = this.hitWindow.greatWindow;
575
995
  break;
576
- case exports.HitResult.good:
577
- firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
996
+ case osuBase.HitResult.Good:
997
+ firstObjectHitWindow = this.hitWindow.okWindow;
578
998
  break;
579
999
  default:
580
- firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
1000
+ firstObjectHitWindow = this.hitWindow.mehWindow;
581
1001
  }
582
1002
  }
583
1003
  // For sliders, automatically set hit window length to be as lenient as possible.
584
- let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
585
- if (objects.at(-1) instanceof osuBase.Circle) {
1004
+ let lastObjectHitWindow = this.hitWindow.mehWindow;
1005
+ if (lastObject instanceof osuBase.Circle) {
586
1006
  switch (lastObjectResult) {
587
- case exports.HitResult.great:
588
- lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
1007
+ case osuBase.HitResult.Great:
1008
+ lastObjectHitWindow = this.hitWindow.greatWindow;
589
1009
  break;
590
- case exports.HitResult.good:
591
- lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
1010
+ case osuBase.HitResult.Good:
1011
+ lastObjectHitWindow = this.hitWindow.okWindow;
592
1012
  break;
593
1013
  default:
594
- lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
1014
+ lastObjectHitWindow = this.hitWindow.mehWindow;
595
1015
  }
596
1016
  }
1017
+ else if (lastObject instanceof osuBase.Slider) {
1018
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
1019
+ }
597
1020
  // These hit time uses hit window length as threshold.
598
1021
  // This is because cursors aren't recorded exactly at hit time,
599
1022
  // probably due to the game's behavior.
600
- const firstObjectHitTime = objects[0].startTime - firstObjectHitWindow;
601
- const lastObjectHitTime = objects.at(-1).startTime + lastObjectHitWindow;
602
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
603
- const cursorInstance = this.data.cursorMovement[i];
1023
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
1024
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
1025
+ for (const cursorInstance of this.data.cursorMovement) {
604
1026
  const validOccurrences = [];
605
- for (let j = 0; j < cursorInstance.occurrenceGroups.length; ++j) {
606
- const group = cursorInstance.occurrenceGroups[j];
1027
+ for (const group of cursorInstance.occurrenceGroups) {
607
1028
  if (group.startTime < firstObjectHitTime) {
608
1029
  continue;
609
1030
  }
@@ -624,329 +1045,231 @@ class ThreeFingerChecker {
624
1045
  * detect dragged sections and improve detection speed.
625
1046
  */
626
1047
  getBeatmapSections() {
1048
+ const beatmapObjects = this.beatmap.hitObjects.objects;
1049
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1050
+ // This intentionally starts from 1 because we need to look at the previous cursor.
1051
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
1052
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
627
1053
  for (const section of this.difficultyAttributes
628
1054
  .possibleThreeFingeredSections) {
629
- this.beatmapSections.push({
630
- ...section,
631
- isDragged: false,
632
- dragFingerIndex: -1,
633
- });
634
- }
635
- }
636
- /**
637
- * Checks whether or not each beatmap sections is dragged.
638
- */
639
- detectDragPlay() {
640
- for (let i = 0; i < this.beatmapSections.length; ++i) {
641
- const dragIndex = this.checkDrag(this.beatmapSections[i]);
642
- this.beatmapSections[i].dragFingerIndex = dragIndex;
643
- this.beatmapSections[i].isDragged = dragIndex !== -1;
1055
+ const objects = [];
1056
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
1057
+ const object = beatmapObjects[i];
1058
+ const objectData = this.data.hitObjectData[i];
1059
+ objects.push({
1060
+ object: object,
1061
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
1062
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
1063
+ });
1064
+ }
1065
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
644
1066
  }
645
1067
  }
646
1068
  /**
647
- * Checks if a section is dragged and returns the index of the drag finger.
1069
+ * Obtains the index of the cursor that aimed the object at the nearest time.
648
1070
  *
649
- * If the section is not dragged, -1 will be returned.
650
- *
651
- * @param section The section to check.
652
- */
653
- checkDrag(section) {
654
- const { objects } = this.beatmap.hitObjects;
655
- const objectData = this.data.hitObjectData;
656
- const firstObject = objects[section.firstObjectIndex];
657
- const lastObject = objects[section.lastObjectIndex];
658
- let firstObjectMinHitTime = firstObject.startTime;
659
- if (firstObject instanceof osuBase.Circle) {
660
- switch (objectData[section.firstObjectIndex].result) {
661
- case exports.HitResult.great:
662
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
663
- break;
664
- case exports.HitResult.good:
665
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
666
- break;
667
- default:
668
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
1071
+ * @param object The object to obtain the index for.
1072
+ * @param objectData The hit data of the object.
1073
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
1074
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
1075
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
1076
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1077
+ */
1078
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
1079
+ if (objectData.result === osuBase.HitResult.Miss || object instanceof osuBase.Spinner) {
1080
+ return -1;
1081
+ }
1082
+ // Check for sliderbreaks and treat them as misses.
1083
+ if (object instanceof osuBase.Slider) {
1084
+ let lateHitThreshold = this.hitWindow.mehWindow;
1085
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
1086
+ if (this.data.replayVersion < 8) {
1087
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
669
1088
  }
670
- }
671
- else {
672
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
673
- }
674
- let lastObjectMaxHitTime = lastObject.startTime;
675
- if (lastObject instanceof osuBase.Circle) {
676
- switch (objectData[section.lastObjectIndex].result) {
677
- case exports.HitResult.great:
678
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
679
- break;
680
- case exports.HitResult.good:
681
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
682
- break;
683
- default:
684
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
1089
+ if (objectData.accuracy < -this.hitWindow.mehWindow ||
1090
+ objectData.accuracy > lateHitThreshold) {
1091
+ return -1;
685
1092
  }
686
1093
  }
687
- else {
688
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
689
- }
690
- // Since there may be more than 1 cursor instance index,
691
- // we check which cursor instance follows hitobjects all over.
692
- const cursorIndexes = [];
1094
+ const hitTime = object.startTime + objectData.accuracy;
1095
+ const objectPosition = object.stackedPosition;
1096
+ // We are maintaining the closest distance to the object.
1097
+ // This is because the radius that is calculated is using an estimation.
1098
+ // As such, it does not reflect the actual object radius in gameplay.
1099
+ let closestDistance = Number.POSITIVE_INFINITY;
1100
+ let nearestCursorIndex = -1;
1101
+ // Observe the cursor position at the object's hit time.
693
1102
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
694
- const c = this.data.cursorMovement[i];
695
- if (c.occurrenceGroups.length === 0) {
696
- continue;
697
- }
698
- // Do not include cursors that don't have an occurence in this section
699
- // this speeds up checking process.
700
- if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
701
- v.endTime <= lastObjectMaxHitTime).length === 0) {
702
- continue;
703
- }
704
- // If this cursor instance doesn't move, it's not the cursor instance we want.
705
- if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
706
- 0) {
707
- continue;
708
- }
709
- cursorIndexes.push(i);
710
- }
711
- return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
712
- }
713
- /**
714
- * Finds the drag index of the section.
715
- *
716
- * @param sectionObjects The objects in the section.
717
- * @param sectionReplayObjectData The hitobject data of all objects in the section.
718
- * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
719
- */
720
- findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
721
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
722
- for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
723
- let object = sectionObjects[i];
724
- const objectData = sectionReplayObjectData[i];
725
- if (object instanceof osuBase.Spinner ||
726
- objectData.result === exports.HitResult.miss) {
727
- continue;
728
- }
729
- // Exclude sliderbreaks.
730
- if (object instanceof osuBase.Slider &&
731
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
732
- continue;
733
- }
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
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
740
- const hitTime = object.startTime + objectData.accuracy;
741
- // Observe the cursor position at the object's hit time.
742
- for (let j = 0; j < cursorIndexes.length; ++j) {
743
- if (cursorIndexes[j] === -1) {
1103
+ const cursorData = this.data.cursorMovement[i];
1104
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
1105
+ const cursorGroup = cursorData.occurrenceGroups[j];
1106
+ if (cursorGroup.endTime < hitTime) {
1107
+ // Reset cursor index pointer.
1108
+ cursorIndices[i] = 1;
744
1109
  continue;
745
1110
  }
746
- const cursorData = this.data.cursorMovement[cursorIndexes[j]];
747
- const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
748
- if (!cursorGroup) {
749
- continue;
1111
+ if (cursorGroup.startTime > hitTime) {
1112
+ break;
750
1113
  }
751
1114
  const cursors = cursorGroup.allOccurrences;
752
- for (let k = 1; k < cursors.length; ++k) {
1115
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
753
1116
  const cursor = cursors[k];
754
1117
  const prevCursor = cursors[k - 1];
755
- if (prevCursor.time < object.startTime - hitWindow50) {
756
- continue;
757
- }
758
- if (prevCursor.time > object.startTime + hitWindow50) {
1118
+ // Cursor is past the object's hit time.
1119
+ if (prevCursor.time > hitTime) {
759
1120
  break;
760
1121
  }
761
- let isInObject = false;
1122
+ // Cursor is before the object's hit time.
1123
+ if (hitTime > cursor.time) {
1124
+ continue;
1125
+ }
1126
+ let distance;
1127
+ const currentPosition = this.getCursorPosition(cursor);
1128
+ const prevPosition = this.getCursorPosition(prevCursor);
762
1129
  switch (cursor.id) {
763
- case exports.MovementType.up:
764
- isInObject =
765
- prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
1130
+ case exports.MovementType.Up:
1131
+ distance = prevPosition.getDistance(objectPosition);
766
1132
  break;
767
- case exports.MovementType.move:
1133
+ case exports.MovementType.Move: {
768
1134
  // Interpolate movement.
769
- for (let mSecPassed = prevCursor.time; !isInObject &&
770
- mSecPassed <=
771
- Math.min(cursor.time, object.startTime + hitWindow50); ++mSecPassed) {
772
- const t = (mSecPassed - prevCursor.time) /
773
- (cursor.time - prevCursor.time);
774
- 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
- isInObject =
776
- objectPosition.getDistance(cursorPosition) <= object.getRadius(osuBase.Modes.droid);
777
- }
1135
+ const t = (hitTime - prevCursor.time) /
1136
+ (cursor.time - prevCursor.time);
1137
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1138
+ distance =
1139
+ objectPosition.getDistance(cursorPosition);
1140
+ break;
1141
+ }
1142
+ case exports.MovementType.Down:
1143
+ continue;
778
1144
  }
779
- if (!isInObject) {
780
- cursorIndexes[j] = -1;
1145
+ if (closestDistance > distance) {
1146
+ closestDistance = distance;
1147
+ nearestCursorIndex = i;
781
1148
  }
782
1149
  }
1150
+ // Reset cursor index pointer on end of group.
1151
+ if (cursorIndices[i] === cursors.length) {
1152
+ cursorIndices[i] = 1;
1153
+ }
1154
+ break;
783
1155
  }
1156
+ // The previous object may still be hit with the same cursor group or cursor index.
1157
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
1158
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
784
1159
  }
785
- return cursorIndexes.find((v) => v !== -1) ?? -1;
1160
+ return nearestCursorIndex;
786
1161
  }
787
1162
  /**
788
- * Attempts to prevent accidental taps from being flagged.
1163
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
789
1164
  *
790
- * This detection will filter cursors that don't hit
791
- * any object in beatmap sections, thus eliminating any
792
- * unnecessary taps.
793
- */
794
- preventAccidentalTaps() {
795
- let filledCursorAmount = this.downCursorInstances.filter((v) => v.length > 0).length;
796
- if (filledCursorAmount <= 3) {
797
- return;
1165
+ * @param object The object to obtain the index for.
1166
+ * @param objectData The hit data of the object.
1167
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1168
+ * @param excludedIndices The cursor indices that should not be checked.
1169
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1170
+ */
1171
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
1172
+ if (objectData.result === osuBase.HitResult.Miss || object instanceof osuBase.Spinner) {
1173
+ return -1;
1174
+ }
1175
+ // Check for sliderbreaks and treat them as misses.
1176
+ if (object instanceof osuBase.Slider) {
1177
+ let lateHitThreshold = this.hitWindow.mehWindow;
1178
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
1179
+ if (this.data.replayVersion < 8) {
1180
+ lateHitThreshold = Math.min(lateHitThreshold, object.duration);
1181
+ }
1182
+ if (objectData.accuracy < -this.hitWindow.mehWindow ||
1183
+ objectData.accuracy > lateHitThreshold) {
1184
+ return -1;
1185
+ }
798
1186
  }
799
- const { objects } = this.beatmap.hitObjects;
800
- const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.length, 0);
1187
+ const hitTime = object.startTime + objectData.accuracy;
1188
+ let nearestCursorInstanceIndex = -1;
1189
+ let nearestTime = Number.POSITIVE_INFINITY;
801
1190
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
802
- if (filledCursorAmount <= 3) {
803
- break;
1191
+ const cursors = this.downCursorInstances[i];
1192
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
1193
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
1194
+ const cursor = cursors[j];
1195
+ if (cursor.time > hitTime) {
1196
+ break;
1197
+ }
1198
+ cursorNearestTime = hitTime - cursor.time;
804
1199
  }
805
- const cursorInstances = this.downCursorInstances[i];
806
- // Use an estimation for accidental tap threshold.
807
- if (cursorInstances.length <=
808
- Math.ceil(objects.length / this.accidentalTapThreshold) &&
809
- cursorInstances.length / totalCursorAmount <
810
- this.threeFingerRatioThreshold * 2) {
811
- --filledCursorAmount;
812
- cursorInstances.length = 0;
1200
+ if (cursorNearestTime < nearestTime) {
1201
+ nearestCursorInstanceIndex = i;
1202
+ nearestTime = cursorNearestTime;
813
1203
  }
814
- this.downCursorInstances[i] = cursorInstances;
815
1204
  }
1205
+ return nearestCursorInstanceIndex;
816
1206
  }
817
1207
  /**
818
1208
  * Creates nerf factors by scanning through objects.
819
- *
820
- * This check will ignore all objects with speed strain below `strainThreshold`.
821
1209
  */
822
1210
  calculateNerfFactors() {
823
- const { objects } = this.beatmap.hitObjects;
824
- const objectData = this.data.hitObjectData;
825
- // We only filter cursor instances that are above the strain threshold.
826
- // This minimalizes the amount of cursor instances to analyze.
827
1211
  for (const beatmapSection of this.beatmapSections) {
828
- const dragIndex = beatmapSection.dragFingerIndex;
829
- const startTime = objects[beatmapSection.firstObjectIndex].startTime +
830
- (objectData[beatmapSection.firstObjectIndex].result !==
831
- exports.HitResult.miss
832
- ? objectData[beatmapSection.firstObjectIndex].accuracy
833
- : -this.hitWindow.hitWindowFor50(this.isPrecise));
834
- const endTime = objects[beatmapSection.lastObjectIndex].endTime +
835
- (objectData[beatmapSection.lastObjectIndex].result !==
836
- exports.HitResult.miss
837
- ? objectData[beatmapSection.lastObjectIndex].accuracy
838
- : this.hitWindow.hitWindowFor50(this.isPrecise));
839
- const cursorAmounts = [];
840
- const cursorVectorTimes = [];
841
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
842
- // Do not include drag cursor instance.
843
- if (i === dragIndex) {
1212
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
1213
+ for (const object of beatmapSection.objects) {
1214
+ if (object.pressingCursorInstanceIndex === -1) {
844
1215
  continue;
845
1216
  }
846
- const cursors = this.downCursorInstances[i];
847
- let amount = 0;
848
- for (let j = 0; j < cursors.length; ++j) {
849
- if (cursors[j].time >= startTime &&
850
- cursors[j].time <= endTime) {
851
- ++amount;
852
- cursorVectorTimes.push({
853
- vector: new osuBase.Vector2(cursors[j].position.x, cursors[j].position.y),
854
- time: cursors[j].time,
855
- });
1217
+ if (object.aimingCursorInstanceIndex < 3) {
1218
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
1219
+ switch (object.pressingCursorInstanceIndex) {
1220
+ case 0:
1221
+ case 1:
1222
+ case 2:
1223
+ break;
1224
+ default:
1225
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
1226
+ break;
856
1227
  }
857
1228
  }
858
- cursorAmounts.push(amount);
859
- }
860
- // This index will be used to detect if a section is 3-fingered.
861
- // If the section is dragged, the dragged instance will be ignored,
862
- // hence why the index is 1 less than nondragged section.
863
- const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
864
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
865
- // to check if the section is 3-fingered.
866
- const threeFingerRatio = cursorAmounts
867
- .slice(fingerSplitIndex)
868
- .reduce((acc, value) => acc + value, 0) /
869
- cursorAmounts
870
- .slice(0, fingerSplitIndex)
871
- .reduce((acc, value) => acc + value, 0);
872
- const similarPresses = [];
873
- for (const cursorVectorTime of cursorVectorTimes) {
874
- const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
875
- this.cursorDistancingDistanceThreshold);
876
- if (pressIndex !== -1) {
877
- if (cursorVectorTime.time -
878
- similarPresses[pressIndex].lastTime >=
879
- this.cursorDistancingTimeThreshold) {
880
- similarPresses.splice(pressIndex, 1);
881
- similarPresses.push({
882
- vector: cursorVectorTime.vector,
883
- count: 1,
884
- lastTime: cursorVectorTime.time,
885
- });
886
- continue;
1229
+ else {
1230
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
1231
+ switch (object.pressingCursorInstanceIndex) {
1232
+ case 0:
1233
+ case 1:
1234
+ break;
1235
+ default:
1236
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
1237
+ break;
887
1238
  }
888
- similarPresses[pressIndex].vector = cursorVectorTime.vector;
889
- similarPresses[pressIndex].lastTime = cursorVectorTime.time;
890
- ++similarPresses[pressIndex].count;
891
1239
  }
892
- else {
893
- similarPresses.push({
894
- vector: cursorVectorTime.vector,
895
- count: 1,
896
- lastTime: cursorVectorTime.time,
897
- });
898
- }
899
- }
900
- // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
901
- const validPresses = similarPresses
902
- .filter((v) => v.count >= this.cursorDistancingCountThreshold)
903
- .sort((a, b) => b.count - a.count)
904
- .slice(2);
905
- // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
906
- if ((threeFingerRatio > this.threeFingerRatioThreshold &&
907
- cursorAmounts.filter((v) => v > 1).length >
908
- fingerSplitIndex) ||
909
- validPresses.length > 0) {
910
- // Strain factor
911
- const objectCount = beatmapSection.lastObjectIndex -
912
- beatmapSection.firstObjectIndex +
913
- 1;
914
- // We can ignore the first 3 (2 for drag) filled cursor instances
915
- // since they are guaranteed not 3 finger.
916
- const threeFingerCursorAmounts = cursorAmounts
917
- .slice(fingerSplitIndex)
918
- .filter((amount) => amount > 0);
919
- // Finger factor applies more penalty if more fingers were used.
920
- const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
921
- ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
922
- Math.pow(((index + 1) * value * objectCount) /
923
- this.strainNoteCount, 0.9), 1)
924
- : Math.pow(validPresses.reduce((acc, value, index) => acc +
925
- Math.pow(((index + 1) *
926
- (value.count /
927
- (this
928
- .cursorDistancingCountThreshold *
929
- 2)) *
930
- objectCount) /
931
- this.strainNoteCount, 0.2), 1), 0.2);
932
- // Length factor applies more penalty if there are more 3-fingered object.
933
- const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
934
- this.nerfFactors.push({
935
- strainFactor: Math.max(1, beatmapSection.sumStrain),
936
- fingerFactor,
937
- lengthFactor,
938
- });
939
1240
  }
1241
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
1242
+ if (threeFingerCursorCount === 0) {
1243
+ continue;
1244
+ }
1245
+ const sectionObjectCount = beatmapSection.objects.length;
1246
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
1247
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
1248
+ // Finger factor applies more penalty if more fingers were used.
1249
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1250
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
1251
+ // Length factor applies more penalty if there are more 3-fingered object.
1252
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
1253
+ this.nerfFactors.push({
1254
+ strainFactor: strainFactor,
1255
+ fingerFactor: fingerFactor,
1256
+ lengthFactor: lengthFactor,
1257
+ });
940
1258
  }
941
1259
  }
942
1260
  /**
943
1261
  * Calculates the final penalty.
944
1262
  */
945
1263
  calculateFinalPenalty() {
946
- return (1 +
947
- this.nerfFactors.reduce((a, n) => a +
948
- 0.015 *
949
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
1264
+ return this.nerfFactors.reduce((a, n) => a +
1265
+ 0.015 *
1266
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1267
+ }
1268
+ getCursorPosition(cursor) {
1269
+ if (this.isHardRock) {
1270
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1271
+ }
1272
+ return cursor.position;
950
1273
  }
951
1274
  }
952
1275
 
@@ -955,63 +1278,24 @@ class ThreeFingerChecker {
955
1278
  */
956
1279
  class IndexedHitObject {
957
1280
  /**
958
- * The cursor index that hits the hitobject.
959
- *
960
- * If -1, the detection was unable to find any cursor that attempted to hit
961
- * the hitobject or it did not meet the criteria for detection.
962
- */
963
- cursorIndex;
964
- /**
965
- * The group index of the cursor within the cursor index that hits the hitobject.
966
- *
967
- * If -1, the detection was unable to find any cursor that attempted to hit
968
- * the hitobject or it did not meet the criteria for detection.
969
- */
970
- groupIndex;
971
- /**
972
- * The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
973
- *
974
- * If -1, the detection was unable to find any cursor that attempted to hit
975
- * the hitobject or it did not meet the criteria for detection.
976
- */
977
- occurrenceIndex;
978
- /**
979
- * The angle of the movement of the cursor towards the next hitobject.
980
- */
981
- angle;
982
- /**
983
- * If this is a slider, whether the slider was cheesed.
984
- */
985
- sliderCheesed = false;
986
- /**
987
- * The underlying difficulty hitobject.
988
- */
989
- object;
990
- /**
991
- * The position of the cursor at the end of this hitobject.
992
- *
993
- * Will be altered during detection.
994
- */
995
- endCursorPosition;
996
- /**
997
- * Whether the hitobject is likely two-handed.
998
- */
999
- is2Handed;
1000
- /**
1001
- * @param object The underlying difficulty hitobject.
1281
+ * @param object The underlying hitobject.
1002
1282
  * @param cursorIndex The cursor index that moves towards the hitobject.
1003
1283
  * @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
1004
1284
  * @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
1005
1285
  * @param angle The angle of the movement of the cursor that moves towards the hitobject.
1006
1286
  */
1007
1287
  constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
1288
+ /**
1289
+ * If this is a slider, whether the slider was cheesed.
1290
+ */
1291
+ this.sliderCheesed = false;
1008
1292
  this.object = object;
1009
1293
  this.cursorIndex = cursorIndex;
1010
1294
  this.groupIndex = groupIndex;
1011
1295
  this.occurrenceIndex = occurrenceIndex;
1012
1296
  this.angle = angle;
1013
1297
  this.is2Handed = is2Handed;
1014
- this.endCursorPosition = this.object.object.getStackedEndPosition(osuBase.Modes.droid);
1298
+ this.endCursorPosition = this.object.stackedEndPosition;
1015
1299
  }
1016
1300
  }
1017
1301
 
@@ -1020,41 +1304,26 @@ class IndexedHitObject {
1020
1304
  * Utility to check whether or not a beatmap is two-handed.
1021
1305
  */
1022
1306
  class TwoHandChecker {
1023
- /**
1024
- * The difficulty calculator that is being analyzed.
1025
- */
1026
- calculator;
1027
- /**
1028
- * The data of the replay.
1029
- */
1030
- data;
1031
- /**
1032
- * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1033
- */
1034
- indexedHitObjects = [];
1035
- /**
1036
- * The osu!droid hitwindow of the analyzed beatmap.
1037
- */
1038
- hitWindow;
1039
- /**
1040
- * The 50 osu!droid hit window of the analyzed beatmap.
1041
- */
1042
- hitWindow50;
1043
1307
  // private csvString: string;
1044
1308
  /**
1045
- * @param calculator The difficulty calculator to analyze.
1309
+ * @param beatmap The beatmap to analyze.
1310
+ * @param attributes The difficulty attributes to analyze.
1046
1311
  * @param data The data of the replay.
1047
1312
  */
1048
- constructor(calculator, data) {
1049
- this.calculator = calculator;
1313
+ constructor(beatmap, attributes, data) {
1314
+ /**
1315
+ * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1316
+ */
1317
+ this.indexedHitObjects = [];
1318
+ this.beatmap = beatmap;
1319
+ this.attributes = attributes;
1050
1320
  this.data = data;
1051
- const stats = new osuBase.MapStats({
1052
- od: this.calculator.beatmap.difficulty.od,
1053
- mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
1054
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1055
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1056
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1057
- this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1321
+ const greatWindow = new osuBase.OsuHitWindow(attributes.overallDifficulty).greatWindow *
1322
+ attributes.clockRate;
1323
+ this.hitWindow = attributes.mods.has(osuBase.ModPrecise)
1324
+ ? new osuBase.PreciseDroidHitWindow(osuBase.PreciseDroidHitWindow.greatWindowToOD(greatWindow))
1325
+ : new osuBase.DroidHitWindow(osuBase.DroidHitWindow.greatWindowToOD(greatWindow));
1326
+ this.isHardRock = attributes.mods.has(osuBase.ModHardRock);
1058
1327
  // this.csvString = `Mods,${
1059
1328
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
1060
1329
  // }\nCombo,${data.maxCombo}\nAccuracy,"${(
@@ -1114,26 +1383,36 @@ class TwoHandChecker {
1114
1383
  // ),
1115
1384
  // this.csvString
1116
1385
  // );
1117
- let twoHandedNoteCount = 0;
1118
- const maxStrain = Math.max(...this.calculator.objects.map((v) => v.aimStrainWithSliders));
1119
- if (maxStrain) {
1120
- twoHandedNoteCount = this.indexedHitObjects.reduce((total, object) => {
1121
- if (!object.is2Handed) {
1122
- return total;
1123
- }
1124
- return (total +
1125
- 1 /
1126
- (1 +
1127
- Math.exp(-((object.object.aimStrainWithSliders /
1128
- maxStrain) *
1129
- 12 -
1130
- 6))));
1131
- }, 0);
1132
- }
1386
+ // let twoHandedNoteCount = 0;
1387
+ // const maxStrain = Math.max(
1388
+ // ...this.attributes.objects.map((v) => v.aimStrainWithSliders),
1389
+ // );
1390
+ // if (maxStrain) {
1391
+ // twoHandedNoteCount = this.indexedHitObjects.reduce(
1392
+ // (total, object) => {
1393
+ // if (!object.is2Handed) {
1394
+ // return total;
1395
+ // }
1396
+ // return (
1397
+ // total +
1398
+ // 1 /
1399
+ // (1 +
1400
+ // Math.exp(
1401
+ // -(
1402
+ // (object.object.aimStrainWithSliders /
1403
+ // maxStrain) *
1404
+ // 12 -
1405
+ // 6
1406
+ // ),
1407
+ // ))
1408
+ // );
1409
+ // },
1410
+ // 0,
1411
+ // );
1412
+ // }
1133
1413
  return {
1134
- is2Hand: twoHandedNoteCount >
1135
- this.calculator.attributes.aimNoteCount * 0.15,
1136
- twoHandedNoteCount: twoHandedNoteCount,
1414
+ is2Hand: false, // 0 > this.attributes.aimNoteCount * 0.15,
1415
+ twoHandedNoteCount: 0,
1137
1416
  };
1138
1417
  }
1139
1418
  /**
@@ -1141,8 +1420,7 @@ class TwoHandChecker {
1141
1420
  */
1142
1421
  indexHitObjects() {
1143
1422
  const indexes = [];
1144
- for (let i = 0; i <
1145
- Math.min(this.data.hitObjectData.length, this.calculator.objects.length); ++i) {
1423
+ for (let i = 0; i < this.data.hitObjectData.length; ++i) {
1146
1424
  const indexedHitObject = this.getIndexedHitObject(i);
1147
1425
  indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i]);
1148
1426
  indexes.push(indexedHitObject.cursorIndex);
@@ -1203,43 +1481,40 @@ class TwoHandChecker {
1203
1481
  * @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.
1204
1482
  */
1205
1483
  getIndexedHitObject(objectIndex) {
1206
- const diffObject = this.calculator.objects[objectIndex];
1207
- const { object } = diffObject;
1484
+ const object = this.beatmap.hitObjects.objects[objectIndex];
1208
1485
  // We don't care about the first object and spinners.
1209
1486
  if (objectIndex === 0 || object instanceof osuBase.Spinner) {
1210
- return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1487
+ return new IndexedHitObject(object, -1, -1, -1, null, false);
1211
1488
  }
1212
1489
  // We don't care if the aim strain is too low.
1213
1490
  // if (diffObject.aimStrainWithSliders < 200) {
1214
1491
  // return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1215
1492
  // }
1216
- const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1493
+ const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
1217
1494
  const prevObjectData = this.data.hitObjectData[objectIndex - 1];
1218
1495
  if (prevObject instanceof osuBase.Spinner ||
1219
- prevObjectData.result === exports.HitResult.miss) {
1220
- return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1496
+ prevObjectData.result === osuBase.HitResult.Miss) {
1497
+ return new IndexedHitObject(object, -1, -1, -1, null, false);
1221
1498
  }
1222
- const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1223
- let prevObjectEndPosition = prevObject.getStackedEndPosition(osuBase.Modes.droid);
1499
+ const objectStartPosition = object.stackedPosition;
1500
+ let prevObjectEndPosition = prevObject.stackedEndPosition;
1224
1501
  if (prevObject instanceof osuBase.Slider) {
1225
- if (prevObject.lazyTravelDistance > 0) {
1226
- const lazyEndMovement = objectStartPosition.subtract(prevObject.lazyEndPosition);
1502
+ if (prevObject.distance > 0) {
1503
+ const endPosition = prevObject.stackedEndPosition;
1504
+ const lazyEndMovement = objectStartPosition.subtract(endPosition);
1227
1505
  const actualEndMovement = objectStartPosition.subtract(prevObjectEndPosition);
1228
1506
  if (lazyEndMovement.length < actualEndMovement.length) {
1229
- prevObjectEndPosition = prevObject.lazyEndPosition;
1507
+ prevObjectEndPosition = endPosition;
1230
1508
  }
1231
1509
  }
1232
1510
  else {
1233
- prevObjectEndPosition = prevObject.getStackedPosition(osuBase.Modes.droid);
1511
+ prevObjectEndPosition = prevObject.stackedPosition;
1234
1512
  }
1235
1513
  }
1236
- const prevToCurrentMovement = object
1237
- .getStackedPosition(osuBase.Modes.droid)
1238
- .subtract(prevObjectEndPosition);
1239
- const radius = object.getRadius(osuBase.Modes.droid);
1514
+ const prevToCurrentMovement = object.stackedPosition.subtract(prevObjectEndPosition);
1240
1515
  // Don't consider objects that are too close to each other.
1241
- if (prevToCurrentMovement.length <= radius) {
1242
- return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1516
+ if (prevToCurrentMovement.length <= object.radius) {
1517
+ return new IndexedHitObject(object, -1, -1, -1, null, false);
1243
1518
  }
1244
1519
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
1245
1520
  // the next object in fast patterns. We should not be worried about slow patterns as they will only
@@ -1259,12 +1534,12 @@ class TwoHandChecker {
1259
1534
  this.indexedHitObjects[objectIndex - 1].endCursorPosition =
1260
1535
  prevObjectInformation.position;
1261
1536
  if (prevObjectInformation.position.x === Number.POSITIVE_INFINITY) {
1262
- return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1537
+ return new IndexedHitObject(object, -1, -1, -1, null, false);
1263
1538
  }
1264
1539
  if (prevObjectInformation.cursorIndex ===
1265
1540
  objectInformation.cursorIndex &&
1266
1541
  prevObjectInformation.groupIndex === objectInformation.groupIndex) {
1267
- return new IndexedHitObject(diffObject, objectInformation.cursorIndex, objectInformation.groupIndex, prevObjectInformation.occurrenceIndex, 0, false);
1542
+ return new IndexedHitObject(object, objectInformation.cursorIndex, objectInformation.groupIndex, prevObjectInformation.occurrenceIndex, 0, false);
1268
1543
  }
1269
1544
  const cursorData = this.data.cursorMovement[prevObjectInformation.cursorIndex];
1270
1545
  // There can be multiple angles to which the cursor moves towards the next object.
@@ -1276,13 +1551,15 @@ class TwoHandChecker {
1276
1551
  for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
1277
1552
  const cursor = cursors[i];
1278
1553
  const prevCursor = cursors[i - 1];
1279
- if (cursor.position.equals(prevCursor.position)) {
1554
+ const currentPosition = this.getCursorPosition(cursor);
1555
+ const prevPosition = this.getCursorPosition(prevCursor);
1556
+ if (currentPosition.equals(prevPosition)) {
1280
1557
  continue;
1281
1558
  }
1282
- if (cursor.id === exports.MovementType.up) {
1559
+ if (cursor.id === exports.MovementType.Up) {
1283
1560
  break;
1284
1561
  }
1285
- const currentMovement = cursor.position.subtract(prevCursor.position);
1562
+ const currentMovement = currentPosition.subtract(prevPosition);
1286
1563
  const dot = prevToCurrentCursorMovement.dot(currentMovement);
1287
1564
  const det = prevToCurrentCursorMovement.x * currentMovement.y -
1288
1565
  prevToCurrentCursorMovement.y * currentMovement.x;
@@ -1344,9 +1621,9 @@ class TwoHandChecker {
1344
1621
  // }
1345
1622
  // }
1346
1623
  if (!Number.isFinite(finalAngle)) {
1347
- return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1624
+ return new IndexedHitObject(object, -1, -1, -1, null, false);
1348
1625
  }
1349
- return new IndexedHitObject(diffObject, prevObjectInformation.cursorIndex, prevObjectInformation.groupIndex, prevObjectInformation.occurrenceIndex, finalAngle, is2Handed);
1626
+ return new IndexedHitObject(object, prevObjectInformation.cursorIndex, prevObjectInformation.groupIndex, prevObjectInformation.occurrenceIndex, finalAngle, is2Handed);
1350
1627
  }
1351
1628
  /**
1352
1629
  * Gets the position of the cursor that presses an object.
@@ -1355,9 +1632,9 @@ class TwoHandChecker {
1355
1632
  * @returns The position of the cursor that presses the object.
1356
1633
  */
1357
1634
  getCursorPositionForObjectStart(objectIndex) {
1358
- const object = this.calculator.beatmap.hitObjects.objects[objectIndex];
1635
+ const object = this.beatmap.hitObjects.objects[objectIndex];
1359
1636
  const data = this.data.hitObjectData[objectIndex];
1360
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
1637
+ const objectPosition = object.stackedPosition;
1361
1638
  if (object instanceof osuBase.Spinner) {
1362
1639
  return {
1363
1640
  position: objectPosition,
@@ -1367,22 +1644,20 @@ class TwoHandChecker {
1367
1644
  cursorTime: object.startTime,
1368
1645
  };
1369
1646
  }
1370
- const radius = object.getRadius(osuBase.Modes.droid);
1371
- let hitWindow = this.hitWindow50;
1372
- const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1647
+ let hitWindow = this.hitWindow.mehWindow;
1373
1648
  // For sliders, set the hit window to as lenient as possible.
1374
1649
  if (object instanceof osuBase.Circle) {
1375
1650
  switch (data.result) {
1376
- case exports.HitResult.great:
1377
- hitWindow = this.hitWindow.hitWindowFor300(isPrecise);
1651
+ case osuBase.HitResult.Great:
1652
+ hitWindow = this.hitWindow.greatWindow;
1378
1653
  break;
1379
- case exports.HitResult.good:
1380
- hitWindow = this.hitWindow.hitWindowFor100(isPrecise);
1654
+ case osuBase.HitResult.Good:
1655
+ hitWindow = this.hitWindow.okWindow;
1381
1656
  break;
1382
1657
  }
1383
1658
  }
1384
1659
  // TODO: what to do for head sliderbreaks?
1385
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1660
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1386
1661
  let nearestCursorIndex = 0;
1387
1662
  let nearestGroupIndex = 0;
1388
1663
  let nearestCursorGroupIndex = 0;
@@ -1390,12 +1665,12 @@ class TwoHandChecker {
1390
1665
  const minimumActiveTime = object.startTime - hitWindow;
1391
1666
  const maximumActiveTime = object.startTime + hitWindow;
1392
1667
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1393
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1668
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1394
1669
  break;
1395
1670
  }
1396
1671
  const cursorData = this.data.cursorMovement[i];
1397
1672
  for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
1398
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1673
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1399
1674
  break;
1400
1675
  }
1401
1676
  const cursorGroup = cursorData.occurrenceGroups[j];
@@ -1407,13 +1682,14 @@ class TwoHandChecker {
1407
1682
  }
1408
1683
  // Validate the down press first.
1409
1684
  const { down } = cursorGroup;
1410
- if (down.position.getDistance(objectPosition) <= radius &&
1685
+ const downPosition = this.getCursorPosition(down);
1686
+ if (downPosition.getDistance(objectPosition) <= object.radius &&
1411
1687
  Math.abs(down.time - object.startTime) <= hitWindow) {
1412
1688
  if (objectIndex > 0) {
1413
- const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1689
+ const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
1414
1690
  if (down.time > prevObject.endTime) {
1415
1691
  return {
1416
- position: down.position,
1692
+ position: downPosition,
1417
1693
  cursorIndex: i,
1418
1694
  groupIndex: j,
1419
1695
  occurrenceIndex: 0,
@@ -1423,7 +1699,7 @@ class TwoHandChecker {
1423
1699
  }
1424
1700
  else {
1425
1701
  return {
1426
- position: down.position,
1702
+ position: downPosition,
1427
1703
  cursorIndex: i,
1428
1704
  groupIndex: j,
1429
1705
  occurrenceIndex: 0,
@@ -1442,8 +1718,8 @@ class TwoHandChecker {
1442
1718
  break;
1443
1719
  }
1444
1720
  let cursorPosition;
1445
- if (cursor.id === exports.MovementType.up) {
1446
- cursorPosition = prevCursor.position;
1721
+ if (cursor.id === exports.MovementType.Up) {
1722
+ cursorPosition = this.getCursorPosition(prevCursor);
1447
1723
  const distance = cursorPosition.getDistance(objectPosition);
1448
1724
  if (distance <
1449
1725
  nearestPosition.getDistance(objectPosition)) {
@@ -1468,7 +1744,7 @@ class TwoHandChecker {
1468
1744
  }
1469
1745
  const t = (cursorGroup.down.time - prevCursor.time) /
1470
1746
  (cursor.time - prevCursor.time);
1471
- cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
1747
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1472
1748
  const distance = cursorPosition.getDistance(objectPosition);
1473
1749
  if (distance <
1474
1750
  nearestPosition.getDistance(objectPosition)) {
@@ -1478,12 +1754,13 @@ class TwoHandChecker {
1478
1754
  nearestCursorGroupIndex = k;
1479
1755
  nearestCursorTime = cursorGroup.down.time;
1480
1756
  }
1481
- if (distance <= radius) {
1757
+ if (distance <= object.radius) {
1482
1758
  break;
1483
1759
  }
1484
1760
  }
1485
1761
  }
1486
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1762
+ if (nearestPosition.getDistance(objectPosition) <=
1763
+ object.radius) {
1487
1764
  break;
1488
1765
  }
1489
1766
  }
@@ -1514,24 +1791,25 @@ class TwoHandChecker {
1514
1791
  * @returns The position of the cursor at the object's end position.
1515
1792
  */
1516
1793
  getCursorPositionForObjectEnd(objectIndex) {
1517
- const object = this.calculator.beatmap.hitObjects.objects[objectIndex];
1794
+ const object = this.beatmap.hitObjects.objects[objectIndex];
1518
1795
  if (!(object instanceof osuBase.Slider)) {
1519
1796
  return this.getCursorPositionForObjectStart(objectIndex);
1520
1797
  }
1521
- const nextObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1522
- let objectEndPosition = object.getStackedEndPosition(osuBase.Modes.droid);
1523
- if (object.lazyTravelDistance > 0 && nextObject) {
1524
- const nextStartPosition = nextObject.getStackedPosition(osuBase.Modes.droid);
1525
- const lazyEndMovement = nextStartPosition.subtract(object.lazyEndPosition);
1798
+ const nextObject = this.beatmap.hitObjects.objects[objectIndex - 1];
1799
+ let objectEndPosition = object.stackedEndPosition;
1800
+ if (object.distance > 0 && nextObject) {
1801
+ const endPosition = object.stackedEndPosition;
1802
+ const nextStartPosition = nextObject.stackedPosition;
1803
+ const lazyEndMovement = nextStartPosition.subtract(endPosition);
1526
1804
  const actualEndMovement = nextStartPosition.subtract(objectEndPosition);
1527
1805
  if (lazyEndMovement.length < actualEndMovement.length) {
1528
- objectEndPosition = object.lazyEndPosition;
1806
+ objectEndPosition = endPosition;
1529
1807
  }
1530
1808
  }
1531
1809
  else {
1532
- objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
1810
+ objectEndPosition = object.stackedPosition;
1533
1811
  }
1534
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1812
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1535
1813
  let nearestCursorIndex = 0;
1536
1814
  let nearestGroupIndex = 0;
1537
1815
  let nearestCursorGroupIndex = 0;
@@ -1551,19 +1829,19 @@ class TwoHandChecker {
1551
1829
  const cursor = cursors[k];
1552
1830
  let cursorPosition;
1553
1831
  switch (cursor.id) {
1554
- case exports.MovementType.down:
1555
- cursorPosition = cursor.position;
1832
+ case exports.MovementType.Down:
1833
+ cursorPosition = this.getCursorPosition(cursor);
1556
1834
  break;
1557
- case exports.MovementType.up: {
1835
+ case exports.MovementType.Up: {
1558
1836
  const prevCursor = cursors[k - 1];
1559
- cursorPosition = prevCursor.position;
1837
+ cursorPosition = this.getCursorPosition(prevCursor);
1560
1838
  break;
1561
1839
  }
1562
- case exports.MovementType.move: {
1840
+ case exports.MovementType.Move: {
1563
1841
  const prevCursor = cursors[k - 1];
1564
1842
  const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
1565
1843
  (cursor.time - prevCursor.time), 0, 1);
1566
- cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
1844
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1567
1845
  break;
1568
1846
  }
1569
1847
  }
@@ -1573,15 +1851,15 @@ class TwoHandChecker {
1573
1851
  nearestCursorIndex = i;
1574
1852
  nearestGroupIndex = j;
1575
1853
  switch (cursor.id) {
1576
- case exports.MovementType.down:
1854
+ case exports.MovementType.Down:
1577
1855
  nearestCursorGroupIndex = k;
1578
1856
  nearestCursorTime = cursor.time;
1579
1857
  break;
1580
- case exports.MovementType.up:
1858
+ case exports.MovementType.Up:
1581
1859
  nearestCursorGroupIndex = k - 1;
1582
1860
  nearestCursorTime = cursors[k - 1].time;
1583
1861
  break;
1584
- case exports.MovementType.move:
1862
+ case exports.MovementType.Move:
1585
1863
  nearestCursorGroupIndex = k;
1586
1864
  nearestCursorTime = object.endTime;
1587
1865
  break;
@@ -1618,376 +1896,683 @@ class TwoHandChecker {
1618
1896
  * @returns Whether the slider was cheesed.
1619
1897
  */
1620
1898
  checkSliderCheesing(indexedHitObject, hitData) {
1621
- if (!(indexedHitObject.object.object instanceof osuBase.Slider) ||
1622
- hitData.result === exports.HitResult.miss ||
1899
+ if (!(indexedHitObject.object instanceof osuBase.Slider) ||
1900
+ hitData.result === osuBase.HitResult.Miss ||
1623
1901
  indexedHitObject.cursorIndex === -1) {
1624
1902
  return false;
1625
1903
  }
1626
1904
  return false;
1627
1905
  }
1906
+ getCursorPosition(cursor) {
1907
+ if (this.isHardRock) {
1908
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1909
+ }
1910
+ return cursor.position;
1911
+ }
1628
1912
  }
1629
1913
 
1630
1914
  /**
1631
- * Utility to check whether relevant sliders in a beatmap are cheesed.
1915
+ * Represents a cursor's occurrence.
1632
1916
  */
1633
- class SliderCheeseChecker {
1917
+ class CursorOccurrence {
1918
+ constructor(time, x, y, id) {
1919
+ this.time = time;
1920
+ this.position = new osuBase.Vector2(x, y);
1921
+ this.id = id;
1922
+ }
1634
1923
  /**
1635
- * The beatmap that is being analyzed.
1924
+ * Returns a string representation of this `CursorOccurrence`.
1636
1925
  */
1637
- beatmap;
1926
+ toString() {
1927
+ let str = `${this.time.toString()}ms `;
1928
+ switch (this.id) {
1929
+ case exports.MovementType.Down:
1930
+ str += "Down";
1931
+ break;
1932
+ case exports.MovementType.Up:
1933
+ str += "Up";
1934
+ break;
1935
+ case exports.MovementType.Move:
1936
+ str += "Move";
1937
+ break;
1938
+ }
1939
+ if (this.id !== exports.MovementType.Up) {
1940
+ str += ` (${this.position.x.toFixed(2)}, ${this.position.y.toFixed(2)})`;
1941
+ }
1942
+ return str;
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * Represents a group of cursor occurrences representing a cursor instance's
1948
+ * movement when a player places their finger on the screen.
1949
+ */
1950
+ class CursorOccurrenceGroup {
1638
1951
  /**
1639
- * The data of the replay.
1952
+ * The cursor occurrence of movement type `movementType.DOWN`.
1640
1953
  */
1641
- data;
1954
+ get down() {
1955
+ return this._down;
1956
+ }
1642
1957
  /**
1643
- * The difficulty attributes of the beatmap.
1958
+ * The cursor occurrence of movement type `movementType.DOWN`.
1644
1959
  */
1645
- difficultyAttributes;
1960
+ set down(value) {
1961
+ if (value.id !== exports.MovementType.Down) {
1962
+ throw new TypeError("Attempting to set the down cursor occurrence to one with different movement type.");
1963
+ }
1964
+ this._down = value;
1965
+ }
1646
1966
  /**
1647
- * The 50 osu!droid hit window of the analyzed beatmap.
1967
+ * The cursor occurrences of movement type `movementType.MOVE`.
1648
1968
  */
1649
- hitWindow50;
1969
+ get moves() {
1970
+ return this._moves;
1971
+ }
1650
1972
  /**
1651
- * @param beatmap The beatmap to analyze.
1652
- * @param data The data of the replay.
1653
- * @param difficultyAttributes The difficulty attributes of the beatmap.
1973
+ * The cursor occurrence of movement type `movementType.UP`.
1974
+ *
1975
+ * May not exist, such as when the player holds their cursor until the end of a beatmap.
1654
1976
  */
1655
- constructor(beatmap, data, difficultyAttributes) {
1656
- this.beatmap = beatmap;
1657
- this.data = data;
1658
- this.difficultyAttributes = difficultyAttributes;
1659
- const stats = new osuBase.MapStats({
1660
- od: this.beatmap.difficulty.od,
1661
- mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
1662
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1663
- }).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
1664
- this.hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1977
+ get up() {
1978
+ return this._up;
1665
1979
  }
1666
1980
  /**
1667
- * Checks if relevant sliders in the given beatmap was cheesed.
1981
+ * The cursor occurrence of movement type `movementType.UP`.
1982
+ *
1983
+ * May not exist, such as when the player holds their cursor until the end of a beatmap.
1668
1984
  */
1669
- check() {
1670
- if (this.difficultyAttributes.difficultSliders.length === 0 ||
1671
- (this.difficultyAttributes.sliderFactor === 1 &&
1672
- this.difficultyAttributes.flashlightSliderFactor === 1 &&
1673
- this.difficultyAttributes.visualSliderFactor === 1)) {
1674
- return {
1675
- aimPenalty: 1,
1676
- flashlightPenalty: 1,
1677
- visualPenalty: 1,
1678
- };
1985
+ set up(value) {
1986
+ if (value && value.id !== exports.MovementType.Up) {
1987
+ throw new TypeError("Attempting to set the up cursor occurrence to one with different movement type.");
1679
1988
  }
1680
- const cheesedDifficultyRatings = this.checkSliderCheesing();
1681
- return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
1989
+ this._up = value;
1682
1990
  }
1683
1991
  /**
1684
- * Checks for sliders that were cheesed.
1992
+ * The time at which this cursor occurrence group starts.
1685
1993
  */
1686
- checkSliderCheesing() {
1687
- const { objects } = this.beatmap.hitObjects;
1688
- const cheesedDifficultyRatings = [];
1689
- // Current loop indices are stored for efficiency.
1690
- const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1691
- const circleSize = new osuBase.MapStats({
1692
- cs: this.beatmap.difficulty.cs,
1693
- mods: this.difficultyAttributes.mods,
1694
- }).calculate({ mode: osuBase.Modes.droid }).cs;
1695
- const scale = osuBase.CircleSizeCalculator.standardCSToStandardScale(circleSize);
1696
- const acceptableRadius = 64 * scale * 2.4;
1697
- // Sort difficult sliders by index so that cursor loop indices work properly.
1698
- for (const difficultSlider of this.difficultyAttributes.difficultSliders
1699
- .slice()
1700
- .sort((a, b) => a.index - b.index)) {
1701
- if (difficultSlider.index >= this.data.hitObjectData.length) {
1702
- continue;
1703
- }
1704
- const objectData = this.data.hitObjectData[difficultSlider.index];
1705
- // If a miss or slider break occurs, we disregard the check for that slider.
1706
- if (objectData.result === exports.HitResult.miss ||
1707
- objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1708
- continue;
1709
- }
1710
- let object = objects[difficultSlider.index];
1711
- if (object.droidScale !== scale) {
1712
- // Deep clone the object so that we can assign scale properly.
1713
- object = osuBase.Utils.deepCopy(object);
1714
- object.droidScale = scale;
1715
- }
1716
- const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1717
- // These time boundaries should consider the delta time between the previous and next
1718
- // object as well as their hit accuracy. However, they are somewhat complicated to
1719
- // compute and the accuracy gain is small. As such, let's settle with 50 hit window.
1720
- const minTimeLimit = object.startTime - this.hitWindow50;
1721
- const maxTimeLimit = object.startTime + this.hitWindow50;
1722
- // Get the closest tap distance across all cursors.
1723
- const closestDistances = [];
1724
- const closestGroupIndices = [];
1725
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1726
- const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
1727
- let closestDistance = Number.POSITIVE_INFINITY;
1728
- let closestIndex = cursorGroups.length;
1729
- for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
1730
- const group = cursorGroups[j];
1731
- if (group.endTime < minTimeLimit) {
1732
- continue;
1733
- }
1734
- if (group.startTime > maxTimeLimit) {
1735
- break;
1736
- }
1737
- if (group.startTime >= minTimeLimit) {
1738
- const distance = group.down.position.getDistance(objectStartPosition);
1739
- if (closestDistance > distance) {
1740
- closestDistance = distance;
1741
- closestIndex = j;
1742
- }
1743
- if (closestDistance <= acceptableRadius / 2) {
1744
- break;
1745
- }
1746
- }
1747
- // Normally, we check if there are cursor presses within the group's active time.
1748
- // However, some funky workarounds are used throughout the game for replays, so
1749
- // for the time being we only check for cursor distances across the group.
1750
- const { allOccurrences } = group;
1751
- for (let k = 1; k < allOccurrences.length; ++k) {
1752
- const occurrence = allOccurrences[k];
1753
- const prevOccurrence = allOccurrences[k - 1];
1754
- let distance = Number.POSITIVE_INFINITY;
1755
- switch (occurrence.id) {
1756
- case exports.MovementType.up:
1757
- distance =
1758
- prevOccurrence.position.getDistance(objectStartPosition);
1759
- break;
1760
- case exports.MovementType.move:
1761
- for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
1762
- Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
1763
- const t = (mSecPassed - prevOccurrence.time) /
1764
- (occurrence.time - prevOccurrence.time);
1765
- const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
1766
- distance =
1767
- cursorPosition.getDistance(objectStartPosition);
1768
- if (closestDistance > distance) {
1769
- closestDistance = distance;
1770
- closestIndex = j;
1771
- }
1772
- if (closestDistance <=
1773
- acceptableRadius / 2) {
1774
- break;
1775
- }
1776
- }
1777
- }
1778
- if (closestDistance > distance) {
1779
- closestDistance = distance;
1780
- closestIndex = j;
1781
- }
1782
- if (closestDistance <= acceptableRadius / 2) {
1783
- break;
1784
- }
1785
- }
1786
- }
1787
- closestDistances.push(closestDistance);
1788
- closestGroupIndices.push(closestIndex);
1789
- if (cursorLoopIndices[i] > 0) {
1790
- // Decrement the index. The previous group may also have a role on the next slider.
1791
- --cursorLoopIndices[i];
1792
- }
1793
- }
1794
- const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
1795
- const closestDistance = closestDistances[cursorIndex];
1796
- if (closestDistance > acceptableRadius / 2) {
1797
- cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
1798
- continue;
1994
+ get startTime() {
1995
+ return this._down.time;
1996
+ }
1997
+ /**
1998
+ * The time at which this cursor occurrence group ends.
1999
+ */
2000
+ get endTime() {
2001
+ var _a, _b, _c, _d;
2002
+ return (_d = (_b = (_a = this._up) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : (_c = this._moves.at(-1)) === null || _c === void 0 ? void 0 : _c.time) !== null && _d !== void 0 ? _d : this._down.time;
2003
+ }
2004
+ /**
2005
+ * The duration this cursor occurrence group is active for.
2006
+ */
2007
+ get duration() {
2008
+ return this.endTime - this.startTime;
2009
+ }
2010
+ /**
2011
+ * All cursor occurrences in this group.
2012
+ *
2013
+ * This iterates all occurrences and as such should be used sparingly or stored locally.
2014
+ */
2015
+ get allOccurrences() {
2016
+ const cursors = [this._down, ...this._moves];
2017
+ if (this._up) {
2018
+ cursors.push(this._up);
2019
+ }
2020
+ return cursors;
2021
+ }
2022
+ constructor(down, moves, up) {
2023
+ this._down = down;
2024
+ this._moves = moves;
2025
+ // Re-set down cursor occurrence for checking.
2026
+ this.down = down;
2027
+ this.up = up;
2028
+ }
2029
+ /**
2030
+ * Determines whether this cursor occurrence group is active at the specified time.
2031
+ *
2032
+ * @param time The time.
2033
+ * @returns Whether this cursor occurrence group is active at the specified time.
2034
+ */
2035
+ isActiveAt(time) {
2036
+ return time >= this.startTime && time <= this.endTime;
2037
+ }
2038
+ /**
2039
+ * Finds the cursor occurrence that is active at a given time.
2040
+ *
2041
+ * @param time The time.
2042
+ * @returns The cursor occurrence at the given time, `null` if not found.
2043
+ */
2044
+ cursorAt(time) {
2045
+ var _a;
2046
+ if (!this.isActiveAt(time)) {
2047
+ return null;
2048
+ }
2049
+ if (this._down.time === time) {
2050
+ return this._down;
2051
+ }
2052
+ if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
2053
+ return this._up;
2054
+ }
2055
+ let l = 0;
2056
+ let r = this._moves.length - 2;
2057
+ while (l <= r) {
2058
+ const pivot = l + ((r - l) >> 1);
2059
+ if (this._moves[pivot].time < time) {
2060
+ l = pivot + 1;
1799
2061
  }
1800
- const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
1801
- let isCheesed = false;
1802
- // Track cursor movement to see if it lands on every tick.
1803
- let occurrenceLoopIndex = 1;
1804
- const { allOccurrences } = group;
1805
- for (let i = 1; i < object.nestedHitObjects.length; ++i) {
1806
- if (isCheesed) {
1807
- break;
1808
- }
1809
- const tickWasHit = objectData.tickset[i - 1];
1810
- if (!tickWasHit) {
1811
- continue;
1812
- }
1813
- const nestedObject = object.nestedHitObjects[i];
1814
- nestedObject.droidScale = scale;
1815
- // Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
1816
- // In that case, its time is assumed to be 36ms behind the slider's end time. However, that
1817
- // is not the case for osu!droid.
1818
- if (nestedObject instanceof osuBase.SliderTail) {
1819
- nestedObject.startTime = object.endTime;
1820
- nestedObject.endTime = object.endTime;
1821
- }
1822
- const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1823
- while (occurrenceLoopIndex < allOccurrences.length &&
1824
- allOccurrences[occurrenceLoopIndex].time <
1825
- nestedObject.startTime) {
1826
- ++occurrenceLoopIndex;
1827
- }
1828
- if (occurrenceLoopIndex === allOccurrences.length) {
1829
- continue;
1830
- }
1831
- const occurrence = allOccurrences[occurrenceLoopIndex];
1832
- const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
1833
- switch (occurrence.id) {
1834
- case exports.MovementType.move: {
1835
- // Interpolate cursor position during nested object time.
1836
- const t = (nestedObject.startTime - prevOccurrence.time) /
1837
- (occurrence.time - prevOccurrence.time);
1838
- const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
1839
- const distance = cursorPosition.getDistance(nestedPosition);
1840
- isCheesed = distance > acceptableRadius;
1841
- break;
1842
- }
1843
- case exports.MovementType.up:
1844
- isCheesed =
1845
- prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
1846
- }
2062
+ else if (this._moves[pivot].time > time) {
2063
+ r = pivot - 1;
1847
2064
  }
1848
- if (isCheesed) {
1849
- cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
2065
+ else {
2066
+ return this._moves[pivot];
1850
2067
  }
1851
2068
  }
1852
- return cheesedDifficultyRatings;
2069
+ // l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
2070
+ return this._moves[l - 1];
1853
2071
  }
1854
2072
  /**
1855
- * Calculates the slider cheese penalty.
2073
+ * Returns a string representation of this `CursorOccurrenceGroup`.
1856
2074
  */
1857
- calculateSliderCheesePenalty(cheesedDifficultyRatings) {
1858
- const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
1859
- return {
1860
- aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
1861
- summedDifficultyRating *
1862
- this.difficultyAttributes.sliderFactor, 2)),
1863
- flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
1864
- summedDifficultyRating *
1865
- this.difficultyAttributes.flashlightSliderFactor, 2)),
1866
- visualPenalty: Math.max(this.difficultyAttributes.visualSliderFactor, Math.pow(1 -
1867
- summedDifficultyRating *
1868
- this.difficultyAttributes.visualSliderFactor, 2)),
1869
- };
2075
+ toString() {
2076
+ return `Down: ${this.down.time.toString()}ms (${this.down.position.x.toFixed(2)}, ${this.down.position.y.toFixed(2)}) | Moves: ${this.moves.length.toString()} | Up: ${this.up ? `${this.up.time.toString()}ms` : "N/A"}`;
1870
2077
  }
1871
2078
  }
1872
2079
 
1873
2080
  /**
1874
- * A replay analyzer that analyzes a replay from osu!droid.
2081
+ * Represents a cursor instance in an osu!droid replay.
1875
2082
  *
1876
- * Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
2083
+ * Stores cursor movement data in the form of `CursorOccurrenceGroup`s.
1877
2084
  *
1878
- * Once analyzed, the result can be accessed via the `data` property.
2085
+ * This is used when analyzing replays using replay analyzer.
1879
2086
  */
1880
- class ReplayAnalyzer {
1881
- /**
1882
- * The score ID of the replay.
1883
- */
1884
- scoreID;
1885
- /**
1886
- * The original odr file of the replay.
1887
- */
1888
- originalODR = null;
1889
- /**
1890
- * The fixed odr file of the replay.
1891
- */
1892
- fixedODR = null;
2087
+ class CursorData {
1893
2088
  /**
1894
- * Whether or not the play is considered using >=3 finger abuse.
2089
+ * The time at which the first occurrence of this cursor instance occurs.
2090
+ *
2091
+ * Will return `null` if there are no occurrences.
1895
2092
  */
1896
- is3Finger;
2093
+ get earliestOccurrenceTime() {
2094
+ var _a, _b;
2095
+ return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
2096
+ }
1897
2097
  /**
1898
- * Whether or not the play is considered 2-handed.
2098
+ * The time at which the latest occurrence of this cursor instance occurs.
2099
+ *
2100
+ * Will return `null` if there are no occurrences.
1899
2101
  */
1900
- is2Hand;
2102
+ get latestOccurrenceTime() {
2103
+ var _a, _b;
2104
+ return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
2105
+ }
1901
2106
  /**
1902
- * The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
2107
+ * The amount of cursor occurrences of this cursor instance.
1903
2108
  */
1904
- beatmap;
2109
+ get totalOccurrences() {
2110
+ return this.occurrenceGroups.reduce((a, v) => {
2111
+ // Down cursor.
2112
+ ++a;
2113
+ // Move cursors.
2114
+ a += v.moves.length;
2115
+ if (v.up) {
2116
+ // Up cursor.
2117
+ ++a;
2118
+ }
2119
+ return a;
2120
+ }, 0);
2121
+ }
1905
2122
  /**
1906
- * The difficulty attributes of the beatmap.
2123
+ * All cursor occurrences of this cursor instnace.
2124
+ *
2125
+ * This iterates all occurrence groups and as such should be used sparingly or stored locally.
1907
2126
  */
1908
- difficultyAttributes;
2127
+ get allOccurrences() {
2128
+ return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
2129
+ }
2130
+ constructor(values) {
2131
+ /**
2132
+ * The occurrence groups of this cursor instance.
2133
+ */
2134
+ this.occurrenceGroups = [];
2135
+ let downOccurrence = null;
2136
+ let moveOccurrences = [];
2137
+ for (let i = 0; i < values.size; ++i) {
2138
+ const occurrence = new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]);
2139
+ switch (occurrence.id) {
2140
+ case exports.MovementType.Down:
2141
+ downOccurrence = occurrence;
2142
+ break;
2143
+ case exports.MovementType.Move:
2144
+ moveOccurrences.push(occurrence);
2145
+ break;
2146
+ case exports.MovementType.Up:
2147
+ if (downOccurrence) {
2148
+ this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences, occurrence));
2149
+ downOccurrence = null;
2150
+ }
2151
+ moveOccurrences = [];
2152
+ }
2153
+ }
2154
+ // Add the final cursor occurrence group as the loop may not catch it for special cases.
2155
+ if (downOccurrence && moveOccurrences.length > 0) {
2156
+ this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences));
2157
+ }
2158
+ }
2159
+ }
2160
+
2161
+ /**
2162
+ * Represents a replay data in an osu!droid replay version 1 and 2.
2163
+ *
2164
+ * Stores generic information about an osu!droid replay.
2165
+ *
2166
+ * This is used when analyzing replays using replay analyzer.
2167
+ */
2168
+ class ReplayData {
2169
+ constructor(values) {
2170
+ this.replayVersion = values.replayVersion;
2171
+ this.folderName = values.folderName;
2172
+ this.fileName = values.fileName;
2173
+ this.hash = values.hash;
2174
+ this.accuracy = values.accuracy;
2175
+ this.rank = values.rank;
2176
+ this.hit300k = values.hit300k;
2177
+ this.hit100k = values.hit100k;
2178
+ this.cursorMovement = values.cursorMovement;
2179
+ this.hitObjectData = values.hitObjectData;
2180
+ }
1909
2181
  /**
1910
- * The results of the analyzer. `null` when initialized.
2182
+ * Whether the replay's version is 3 or later.
1911
2183
  */
1912
- data = null;
2184
+ isReplayV3() {
2185
+ return this.replayVersion >= 3;
2186
+ }
2187
+ }
2188
+
2189
+ /**
2190
+ * Represents a replay data for replay version 3 and later.
2191
+ *
2192
+ * Stores generic information about an osu!droid replay.
2193
+ *
2194
+ * This is used when analyzing replays using replay analyzer.
2195
+ */
2196
+ class ReplayV3Data extends ReplayData {
1913
2197
  /**
1914
- * Penalty value used to penalize dpp for 2-hand.
2198
+ * The total score achieved in the play, after applying score multiplier from mods.
1915
2199
  */
1916
- aimPenalty = 1;
2200
+ get totalScore() {
2201
+ var _a;
2202
+ if (this.replayVersion < 8) {
2203
+ return this.score;
2204
+ }
2205
+ (_a = this.scoreMultiplier) !== null && _a !== void 0 ? _a : (this.scoreMultiplier = osuBase.ModUtil.calculateScoreMultiplier(this.convertedMods.values(), osuBase.Modes.Droid));
2206
+ return Math.round(Math.fround(this.score * this.scoreMultiplier));
2207
+ }
2208
+ constructor(values) {
2209
+ super(values);
2210
+ this.time = values.time;
2211
+ this.score = values.score;
2212
+ this.maxCombo = values.maxCombo;
2213
+ this.isFullCombo = values.isFullCombo;
2214
+ this.playerName = values.playerName;
2215
+ this.convertedMods = values.convertedMods;
2216
+ }
2217
+ }
2218
+
2219
+ /******************************************************************************
2220
+ Copyright (c) Microsoft Corporation.
2221
+
2222
+ Permission to use, copy, modify, and/or distribute this software for any
2223
+ purpose with or without fee is hereby granted.
2224
+
2225
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
2226
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
2227
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
2228
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
2229
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
2230
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
2231
+ PERFORMANCE OF THIS SOFTWARE.
2232
+ ***************************************************************************** */
2233
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
2234
+
2235
+
2236
+ function __awaiter(thisArg, _arguments, P, generator) {
2237
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
2238
+ return new (P || (P = Promise))(function (resolve, reject) {
2239
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
2240
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
2241
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
2242
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
2243
+ });
2244
+ }
2245
+
2246
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
2247
+ var e = new Error(message);
2248
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
2249
+ };
2250
+
2251
+ /**
2252
+ * A replay analyzer that analyzes a replay from osu!droid.
2253
+ *
2254
+ * Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
2255
+ *
2256
+ * Once analyzed, the result can be accessed via the `data` property.
2257
+ */
2258
+ class ReplayAnalyzer {
2259
+ constructor(values) {
2260
+ /**
2261
+ * The original odr file of the replay.
2262
+ */
2263
+ this.originalODR = null;
2264
+ /**
2265
+ * The fixed odr file of the replay.
2266
+ */
2267
+ this.fixedODR = null;
2268
+ /**
2269
+ * The results of the analyzer. `null` when initialized.
2270
+ */
2271
+ this.data = null;
2272
+ /**
2273
+ * Penalty value used to penalize dpp for 2-hand.
2274
+ */
2275
+ this.aimPenalty = 1;
2276
+ /**
2277
+ * Penalty value used to penalize dpp for 3 finger abuse.
2278
+ */
2279
+ this.tapPenalty = 1;
2280
+ /**
2281
+ * Penalty values used to penalize dpp for slider cheesing.
2282
+ */
2283
+ this.sliderCheesePenalty = 1;
2284
+ /**
2285
+ * Whether this replay has been checked against 3 finger usage.
2286
+ */
2287
+ this.hasBeenCheckedFor3Finger = false;
2288
+ /**
2289
+ * Whether this replay has been checked against 2 hand usage.
2290
+ */
2291
+ this.hasBeenCheckedFor2Hand = false;
2292
+ /**
2293
+ * Whether this replay has been checked against slider cheesing.
2294
+ */
2295
+ this.hasBeenCheckedForSliderCheesing = false;
2296
+ /**
2297
+ * The amount of two-handed objects.
2298
+ */
2299
+ this.twoHandedNoteCount = 0;
2300
+ this.bufferOffset = 0;
2301
+ this.scoreID = values === null || values === void 0 ? void 0 : values.scoreID;
2302
+ this.beatmap = values === null || values === void 0 ? void 0 : values.map;
2303
+ this.difficultyAttributes = values === null || values === void 0 ? void 0 : values.difficultyAttributes;
2304
+ if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
2305
+ this.playableBeatmap = this.beatmap;
2306
+ }
2307
+ }
1917
2308
  /**
1918
- * Penalty value used to penalize dpp for 3 finger abuse.
2309
+ * Analyzes a replay.
1919
2310
  */
1920
- tapPenalty = 1;
2311
+ analyze() {
2312
+ return __awaiter(this, void 0, void 0, function* () {
2313
+ var _a;
2314
+ if (!this.originalODR && !this.fixedODR) {
2315
+ this.originalODR = yield this.downloadReplay();
2316
+ }
2317
+ if (!this.originalODR) {
2318
+ return this;
2319
+ }
2320
+ (_a = this.fixedODR) !== null && _a !== void 0 ? _a : (this.fixedODR = yield this.decompress().catch(() => null));
2321
+ if (!this.fixedODR) {
2322
+ return this;
2323
+ }
2324
+ this.parseReplay();
2325
+ return this;
2326
+ });
2327
+ }
1921
2328
  /**
1922
- * Penalty values used to penalize dpp for slider cheesing.
2329
+ * Gets hit error information of the replay.
2330
+ *
2331
+ * `analyze()` must be called before calling this, and `beatmap` must be defined.
1923
2332
  */
1924
- sliderCheesePenalty = {
1925
- aimPenalty: 1,
1926
- flashlightPenalty: 1,
1927
- visualPenalty: 1,
1928
- };
2333
+ calculateHitError() {
2334
+ var _a, _b;
2335
+ if (!this.data || !this.beatmap) {
2336
+ return null;
2337
+ }
2338
+ const hitObjectData = this.data.hitObjectData;
2339
+ let positiveCount = 0;
2340
+ let negativeCount = 0;
2341
+ let positiveTotal = 0;
2342
+ let negativeTotal = 0;
2343
+ const { objects } = this.beatmap.hitObjects;
2344
+ const mods = this.data.isReplayV3()
2345
+ ? this.data.convertedMods
2346
+ : ((_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods) !== null && _b !== void 0 ? _b : new osuBase.ModMap());
2347
+ const adjustedDifficulty = new osuBase.BeatmapDifficulty(this.beatmap.difficulty);
2348
+ osuBase.ModUtil.applyModsToBeatmapDifficulty(adjustedDifficulty, osuBase.Modes.Droid, mods);
2349
+ const mehWindow = mods.has(osuBase.ModPrecise)
2350
+ ? new osuBase.PreciseDroidHitWindow(adjustedDifficulty.od).mehWindow
2351
+ : new osuBase.DroidHitWindow(adjustedDifficulty.od).mehWindow;
2352
+ const accuracies = [];
2353
+ for (let i = 0; i < hitObjectData.length; ++i) {
2354
+ const v = hitObjectData[i];
2355
+ const o = objects[i];
2356
+ if (o instanceof osuBase.Spinner || v.result === osuBase.HitResult.Miss) {
2357
+ continue;
2358
+ }
2359
+ const { accuracy } = v;
2360
+ // Do not include slider breaks.
2361
+ if (o instanceof osuBase.Slider) {
2362
+ let lateHitThreshold = mehWindow;
2363
+ // Before replay version 8, the slider head's hit window is capped to the duration of the slider.
2364
+ if (this.data.replayVersion < 8) {
2365
+ lateHitThreshold = Math.min(mehWindow, o.duration);
2366
+ }
2367
+ if (-mehWindow > accuracy || accuracy > lateHitThreshold) {
2368
+ continue;
2369
+ }
2370
+ }
2371
+ accuracies.push(accuracy);
2372
+ if (accuracy >= 0) {
2373
+ positiveTotal += accuracy;
2374
+ ++positiveCount;
2375
+ }
2376
+ else {
2377
+ negativeTotal += accuracy;
2378
+ ++negativeCount;
2379
+ }
2380
+ }
2381
+ return {
2382
+ positiveAvg: positiveTotal / positiveCount || 0,
2383
+ negativeAvg: negativeTotal / negativeCount || 0,
2384
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2385
+ };
2386
+ }
1929
2387
  /**
1930
- * Whether this replay has been checked against 3 finger usage.
2388
+ * Obtains the amount of slider ticks and ends hit in the replay.
2389
+ *
2390
+ * This requires `analyze()` to be called first and `beatmap` to be defined.
2391
+ *
2392
+ * @returns Slider hit information or `null` if the replay has not been analyzed or the beatmap is not defined.
1931
2393
  */
1932
- hasBeenCheckedFor3Finger = false;
2394
+ obtainSliderHitInformation() {
2395
+ const { data, beatmap } = this;
2396
+ if (!data || !beatmap) {
2397
+ return null;
2398
+ }
2399
+ const sliderInformation = {
2400
+ tick: { obtained: 0, total: beatmap.hitObjects.sliderTicks },
2401
+ end: { obtained: 0, total: beatmap.hitObjects.sliders },
2402
+ };
2403
+ for (let i = 0; i < data.hitObjectData.length; ++i) {
2404
+ const object = beatmap.hitObjects.objects[i];
2405
+ const objectData = data.hitObjectData[i];
2406
+ if (objectData.result === osuBase.HitResult.Miss ||
2407
+ !(object instanceof osuBase.Slider)) {
2408
+ continue;
2409
+ }
2410
+ // Exclude the head circle.
2411
+ for (let j = 1; j < object.nestedHitObjects.length; ++j) {
2412
+ const nested = object.nestedHitObjects[j];
2413
+ if (!objectData.tickset[j - 1]) {
2414
+ continue;
2415
+ }
2416
+ if (nested instanceof osuBase.SliderTick) {
2417
+ ++sliderInformation.tick.obtained;
2418
+ }
2419
+ else if (nested instanceof osuBase.SliderTail) {
2420
+ ++sliderInformation.end.obtained;
2421
+ }
2422
+ }
2423
+ }
2424
+ return sliderInformation;
2425
+ }
1933
2426
  /**
1934
- * Whether this replay has been checked against 2 hand usage.
2427
+ * Simulates a hit window for the replay.
2428
+ *
2429
+ * This does not account for required spins in a spinner.
2430
+ *
2431
+ * Requires `analyze()` to be called first and `beatmap` to be defined.
2432
+ *
2433
+ * @param hitWindow The hit window to simulate.
2434
+ * @returns The accuracy of the replay based on the hit window, or `null` if the replay has not been analyzed or the beatmap is not defined.
1935
2435
  */
1936
- hasBeenCheckedFor2Hand = false;
2436
+ simulateHitWindow(hitWindow) {
2437
+ const { data, beatmap } = this;
2438
+ if (!data || !beatmap) {
2439
+ return null;
2440
+ }
2441
+ const accuracy = new osuBase.Accuracy({ n300: 0, n100: 0, n50: 0, nmiss: 0 });
2442
+ for (let i = 0; i < data.hitObjectData.length; ++i) {
2443
+ const object = beatmap.hitObjects.objects[i];
2444
+ const objectData = data.hitObjectData[i];
2445
+ const hitAccuracy = Math.abs(objectData.accuracy);
2446
+ let { result } = objectData;
2447
+ if (object instanceof osuBase.Circle) {
2448
+ if (hitAccuracy <= hitWindow.greatWindow) {
2449
+ result = osuBase.HitResult.Great;
2450
+ }
2451
+ else if (hitAccuracy <= hitWindow.okWindow) {
2452
+ result = osuBase.HitResult.Good;
2453
+ }
2454
+ else if (hitAccuracy <= hitWindow.mehWindow) {
2455
+ result = osuBase.HitResult.Meh;
2456
+ }
2457
+ else {
2458
+ result = osuBase.HitResult.Miss;
2459
+ }
2460
+ }
2461
+ else if (object instanceof osuBase.Slider) {
2462
+ if (hitAccuracy <=
2463
+ Math.min(hitWindow.mehWindow, object.duration)) {
2464
+ let ticksObtained = 1;
2465
+ for (let j = 1; j < object.nestedHitObjects.length; ++j) {
2466
+ if (objectData.tickset[j - 1]) {
2467
+ ++ticksObtained;
2468
+ }
2469
+ }
2470
+ if (ticksObtained === object.nestedHitObjects.length) {
2471
+ result = osuBase.HitResult.Great;
2472
+ }
2473
+ else if (ticksObtained >=
2474
+ Math.trunc(object.nestedHitObjects.length / 2)) {
2475
+ result = osuBase.HitResult.Good;
2476
+ }
2477
+ else if (ticksObtained > 0) {
2478
+ result = osuBase.HitResult.Meh;
2479
+ }
2480
+ else {
2481
+ result = osuBase.HitResult.Miss;
2482
+ }
2483
+ }
2484
+ else {
2485
+ result = osuBase.HitResult.Miss;
2486
+ }
2487
+ }
2488
+ switch (result) {
2489
+ case osuBase.HitResult.Miss:
2490
+ ++accuracy.nmiss;
2491
+ break;
2492
+ case osuBase.HitResult.Meh:
2493
+ ++accuracy.n50;
2494
+ break;
2495
+ case osuBase.HitResult.Good:
2496
+ ++accuracy.n100;
2497
+ break;
2498
+ case osuBase.HitResult.Great:
2499
+ ++accuracy.n300;
2500
+ break;
2501
+ }
2502
+ }
2503
+ return accuracy;
2504
+ }
1937
2505
  /**
1938
- * Whether this repla has been checked against slider cheesing.
2506
+ * Checks if a play is using 3 fingers.
2507
+ *
2508
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
1939
2509
  */
1940
- hasBeenCheckedForSliderCheesing = false;
2510
+ checkFor3Finger() {
2511
+ var _a;
2512
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2513
+ return;
2514
+ }
2515
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2516
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2517
+ ? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
2518
+ : new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2519
+ const result = threeFingerChecker.check();
2520
+ this.is3Finger = result.is3Finger;
2521
+ this.tapPenalty = result.penalty;
2522
+ this.hasBeenCheckedFor3Finger = true;
2523
+ }
1941
2524
  /**
1942
- * The amount of two-handed objects.
2525
+ * Checks if a play is using 2 hands.
2526
+ *
2527
+ * Requires `analyze()` to be called first as well as `beatmap` and `difficultyAttributes` to be defined.
1943
2528
  */
1944
- twoHandedNoteCount = 0;
1945
- // Sizes of primitive data types in Java (in bytes)
1946
- BYTE_LENGTH = 1;
1947
- SHORT_LENGTH = 2;
1948
- INT_LENGTH = 4;
1949
- FLOAT_LENGTH = 4;
1950
- LONG_LENGTH = 8;
1951
- constructor(values) {
1952
- this.scoreID = values.scoreID;
1953
- this.beatmap = values.map;
1954
- this.difficultyAttributes = values.difficultyAttributes;
1955
- if (this.beatmap && !(this.beatmap instanceof osuBase.Beatmap)) {
1956
- this.difficultyAttributes = this.beatmap.attributes;
2529
+ checkFor2Hand() {
2530
+ var _a;
2531
+ if (!this.beatmap || !this.difficultyAttributes || !this.data) {
2532
+ return;
1957
2533
  }
2534
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2535
+ const twoHandChecker = new TwoHandChecker(this.playableBeatmap, this.difficultyAttributes, this.data);
2536
+ const result = twoHandChecker.check();
2537
+ this.is2Hand = result.is2Hand;
2538
+ this.twoHandedNoteCount = result.twoHandedNoteCount;
2539
+ this.hasBeenCheckedFor2Hand = true;
1958
2540
  }
1959
2541
  /**
1960
- * Analyzes a replay.
2542
+ * Checks if a play has cheesed sliders.
2543
+ *
2544
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
1961
2545
  */
1962
- async analyze() {
1963
- if (!this.originalODR && !this.fixedODR) {
1964
- this.originalODR = await this.downloadReplay();
1965
- }
1966
- if (!this.originalODR) {
1967
- return this;
1968
- }
1969
- if (!this.fixedODR) {
1970
- this.fixedODR = await this.decompress().catch(() => null);
1971
- }
1972
- if (!this.fixedODR) {
1973
- return this;
2546
+ checkForSliderCheesing() {
2547
+ var _a;
2548
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2549
+ return;
1974
2550
  }
1975
- this.parseReplay();
1976
- return this;
2551
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2552
+ const sliderCheeseChecker = this.difficultyAttributes.mode === "rebalance"
2553
+ ? new RebalanceSliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
2554
+ : new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2555
+ this.sliderCheesePenalty = sliderCheeseChecker.check();
2556
+ this.hasBeenCheckedForSliderCheesing = true;
1977
2557
  }
1978
2558
  /**
1979
2559
  * Downloads the given score ID's replay.
1980
2560
  */
1981
- async downloadReplay() {
1982
- const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1983
- .setRequireAPIkey(false)
1984
- .setEndpoint("upload")
1985
- .addParameter("", `${this.scoreID}.odr`);
1986
- const result = await apiRequestBuilder.sendRequest();
1987
- if (result.statusCode !== 200) {
1988
- return null;
1989
- }
1990
- return result.data;
2561
+ downloadReplay() {
2562
+ return __awaiter(this, void 0, void 0, function* () {
2563
+ if (this.scoreID === undefined) {
2564
+ return null;
2565
+ }
2566
+ const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
2567
+ .setRequireAPIkey(false)
2568
+ .setEndpoint("upload")
2569
+ .addParameter("", `${this.scoreID.toString()}.odr`);
2570
+ const result = yield apiRequestBuilder.sendRequest();
2571
+ if (result.statusCode !== 200) {
2572
+ return null;
2573
+ }
2574
+ return result.data;
2575
+ });
1991
2576
  }
1992
2577
  /**
1993
2578
  * Decompresses a replay.
@@ -2001,17 +2586,20 @@ class ReplayAnalyzer {
2001
2586
  stream.push(null);
2002
2587
  stream
2003
2588
  .pipe(unzipper.Parse())
2004
- .on("entry", async (entry) => {
2589
+ .on("entry", (entry) => {
2005
2590
  const fileName = entry.path;
2006
2591
  if (fileName === "data") {
2007
- return resolve(await entry.buffer());
2592
+ resolve(entry.buffer());
2593
+ return;
2008
2594
  }
2009
2595
  else {
2010
2596
  entry.autodrain();
2011
2597
  }
2012
2598
  })
2013
2599
  .on("error", (e) => {
2014
- setTimeout(() => reject(e), 2000);
2600
+ setTimeout(() => {
2601
+ reject(e);
2602
+ }, 2000);
2015
2603
  });
2016
2604
  });
2017
2605
  }
@@ -2019,14 +2607,16 @@ class ReplayAnalyzer {
2019
2607
  * Parses a replay after being downloaded and converted to a buffer.
2020
2608
  */
2021
2609
  parseReplay() {
2610
+ if (!this.fixedODR) {
2611
+ return;
2612
+ }
2022
2613
  // javaDeserialization can only somewhat parse some string field
2023
- // the rest will be a buffer that we need to manually parse
2024
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2614
+ // the rest will be a buffer that we need to manually parse.
2025
2615
  let rawObject;
2026
2616
  try {
2027
2617
  rawObject = javaDeserialization__namespace.parse(this.fixedODR);
2028
2618
  }
2029
- catch {
2619
+ catch (_a) {
2030
2620
  return;
2031
2621
  }
2032
2622
  const resultObject = {
@@ -2037,88 +2627,62 @@ class ReplayAnalyzer {
2037
2627
  cursorMovement: [],
2038
2628
  hitObjectData: [],
2039
2629
  accuracy: new osuBase.Accuracy({ n300: 0 }),
2040
- rank: "",
2041
- };
2042
- const determineRank = () => {
2043
- const totalHits = resultObject.accuracy.n300 +
2044
- resultObject.accuracy.n100 +
2045
- resultObject.accuracy.n50 +
2046
- resultObject.accuracy.nmiss;
2047
- const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
2048
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2049
- switch (true) {
2050
- case resultObject.accuracy.value() === 1:
2051
- if (isHidden) {
2052
- resultObject.rank = "XH";
2053
- }
2054
- else {
2055
- resultObject.rank = "X";
2056
- }
2057
- break;
2058
- case hit300Ratio > 0.9 &&
2059
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2060
- !resultObject.accuracy.nmiss:
2061
- if (isHidden) {
2062
- resultObject.rank = "SH";
2063
- }
2064
- else {
2065
- resultObject.rank = "S";
2066
- }
2067
- break;
2068
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2069
- hit300Ratio > 0.9:
2070
- resultObject.rank = "A";
2071
- break;
2072
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2073
- hit300Ratio > 0.8:
2074
- resultObject.rank = "B";
2075
- break;
2076
- case hit300Ratio > 0.6:
2077
- resultObject.rank = "C";
2078
- break;
2079
- default:
2080
- resultObject.rank = "D";
2081
- }
2630
+ rank: "D",
2631
+ convertedMods: new osuBase.ModMap(),
2632
+ hit100k: 0,
2633
+ hit300k: 0,
2634
+ isFullCombo: false,
2635
+ maxCombo: 0,
2636
+ playerName: "",
2637
+ score: 0,
2638
+ time: new Date(0),
2082
2639
  };
2083
2640
  if (resultObject.replayVersion >= 3) {
2084
- resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
2085
- resultObject.hit300k = rawObject[4].readInt32BE(8);
2086
- resultObject.hit100k = rawObject[4].readInt32BE(16);
2087
- resultObject.score = rawObject[4].readInt32BE(32);
2088
- resultObject.maxCombo = rawObject[4].readInt32BE(36);
2089
- resultObject.accuracy = new osuBase.Accuracy({
2090
- n300: rawObject[4].readInt32BE(12),
2091
- n100: rawObject[4].readInt32BE(20),
2092
- n50: rawObject[4].readInt32BE(24),
2093
- nmiss: rawObject[4].readInt32BE(28),
2094
- });
2095
- resultObject.isFullCombo = !!rawObject[4][44];
2641
+ const buf = rawObject[4];
2642
+ resultObject.time.setTime(Number(buf.readBigUInt64BE(0)));
2643
+ resultObject.hit300k = buf.readInt32BE(8);
2644
+ resultObject.accuracy.n300 = buf.readInt32BE(12);
2645
+ resultObject.hit100k = buf.readInt32BE(16);
2646
+ resultObject.accuracy.n100 = buf.readInt32BE(20);
2647
+ resultObject.accuracy.n50 = buf.readInt32BE(24);
2648
+ resultObject.accuracy.nmiss = buf.readInt32BE(28);
2649
+ resultObject.score = buf.readInt32BE(32);
2650
+ resultObject.maxCombo = buf.readInt32BE(36);
2651
+ resultObject.isFullCombo = resultObject.accuracy.value() === 1;
2096
2652
  resultObject.playerName = rawObject[5];
2097
- resultObject.rawMods = rawObject[6].elements;
2098
- resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
2099
- determineRank();
2100
- }
2101
- if (resultObject.replayVersion >= 4) {
2102
- const s = rawObject[7].split("|");
2103
- resultObject.speedModification =
2104
- parseFloat(s[0].replace("x", "")) || 1;
2105
- if (s.length > 1) {
2106
- resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
2653
+ if (resultObject.replayVersion >= 7) {
2654
+ resultObject.convertedMods = osuBase.ModUtil.deserializeMods(JSON.parse(rawObject[6]));
2655
+ }
2656
+ else {
2657
+ resultObject.convertedMods = this.convertDroidMods(resultObject.replayVersion, Object.values(rawObject[6]
2658
+ .elements));
2659
+ if (resultObject.replayVersion >= 4) {
2660
+ osuBase.DroidLegacyModConverter.parseExtraModString(resultObject.convertedMods, rawObject[7].split("|"));
2661
+ }
2107
2662
  }
2663
+ resultObject.rank = this.calculateRank(resultObject);
2664
+ }
2665
+ if (resultObject.replayVersion <= 6) {
2666
+ resultObject.convertedMods.set(osuBase.ModReplayV6);
2108
2667
  }
2109
2668
  let bufferIndex;
2110
- switch (true) {
2111
- // replay v4 and above
2112
- case resultObject.replayVersion >= 4:
2113
- bufferIndex = 8;
2669
+ switch (resultObject.replayVersion) {
2670
+ case 1:
2671
+ case 2:
2672
+ bufferIndex = 4;
2114
2673
  break;
2115
- // replay v3
2116
- case resultObject.replayVersion === 3:
2674
+ case 3:
2675
+ case 7:
2676
+ case 8:
2117
2677
  bufferIndex = 7;
2118
2678
  break;
2119
- // replay v1 and v2
2679
+ case 4:
2680
+ case 5:
2681
+ case 6:
2682
+ bufferIndex = 8;
2683
+ break;
2120
2684
  default:
2121
- bufferIndex = 4;
2685
+ throw new Error(`Unsupported replay version: ${resultObject.replayVersion.toString()}`);
2122
2686
  }
2123
2687
  const replayDataBufferArray = [];
2124
2688
  while (bufferIndex < rawObject.length) {
@@ -2126,39 +2690,87 @@ class ReplayAnalyzer {
2126
2690
  }
2127
2691
  // Merge all cursor movement and hit object data section into one for better control when parsing
2128
2692
  const replayDataBuffer = Buffer.concat(replayDataBufferArray);
2129
- let bufferCounter = 0;
2130
- const size = replayDataBuffer.readInt32BE(bufferCounter);
2131
- bufferCounter += this.INT_LENGTH;
2132
- // Parse movement data
2133
- for (let x = 0; x < size; x++) {
2134
- const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
2135
- bufferCounter += this.INT_LENGTH;
2693
+ this.bufferOffset = 0;
2694
+ this.parseMovementData(resultObject, replayDataBuffer);
2695
+ this.parseHitObjectData(resultObject, replayDataBuffer);
2696
+ this.parseOldReplayInformation(resultObject);
2697
+ switch (resultObject.replayVersion) {
2698
+ case 1:
2699
+ case 2:
2700
+ this.data = new ReplayData(resultObject);
2701
+ break;
2702
+ default:
2703
+ this.data = new ReplayV3Data(resultObject);
2704
+ }
2705
+ }
2706
+ /**
2707
+ * Converts replay mods to droid mod string.
2708
+ */
2709
+ convertDroidMods(replayVersion, replayMods) {
2710
+ const replayModsConstants = {
2711
+ MOD_AUTO: osuBase.ModAuto,
2712
+ MOD_AUTOPILOT: osuBase.ModAutopilot,
2713
+ MOD_NOFAIL: osuBase.ModNoFail,
2714
+ MOD_EASY: osuBase.ModEasy,
2715
+ MOD_HIDDEN: osuBase.ModHidden,
2716
+ MOD_TRACEABLE: osuBase.ModTraceable,
2717
+ MOD_HARDROCK: osuBase.ModHardRock,
2718
+ MOD_DOUBLETIME: osuBase.ModDoubleTime,
2719
+ MOD_HALFTIME: osuBase.ModHalfTime,
2720
+ MOD_NIGHTCORE: osuBase.ModNightCore,
2721
+ MOD_PRECISE: osuBase.ModPrecise,
2722
+ MOD_SMALLCIRCLE: osuBase.ModSmallCircle,
2723
+ MOD_REALLYEASY: osuBase.ModReallyEasy,
2724
+ MOD_RELAX: osuBase.ModRelax,
2725
+ MOD_PERFECT: osuBase.ModPerfect,
2726
+ MOD_SUDDENDEATH: osuBase.ModSuddenDeath,
2727
+ MOD_SCOREV2: osuBase.ModScoreV2,
2728
+ MOD_FLASHLIGHT: osuBase.ModFlashlight,
2729
+ };
2730
+ const map = new osuBase.ModMap();
2731
+ for (const mod of replayMods) {
2732
+ for (const property in Object(replayModsConstants)) {
2733
+ if (!mod.includes(property)) {
2734
+ continue;
2735
+ }
2736
+ if (replayVersion <= 3 && mod === "MOD_NIGHTCORE") {
2737
+ // In replay v3, the NightCore mod is bugged. See ModOldNightCore's description.
2738
+ map.set(new osuBase.ModOldNightCore());
2739
+ }
2740
+ else {
2741
+ map.set(replayModsConstants[property]);
2742
+ }
2743
+ break;
2744
+ }
2745
+ }
2746
+ return map;
2747
+ }
2748
+ parseMovementData(resultObject, replayDataBuffer) {
2749
+ resultObject.cursorMovement.length = 0;
2750
+ const size = this.readInt(replayDataBuffer);
2751
+ for (let i = 0; i < size; i++) {
2752
+ const moveSize = this.readInt(replayDataBuffer);
2136
2753
  const time = [];
2137
2754
  const x = [];
2138
2755
  const y = [];
2139
2756
  const id = [];
2140
- for (let i = 0; i < moveSize; i++) {
2141
- time[i] = replayDataBuffer.readInt32BE(bufferCounter);
2142
- bufferCounter += this.INT_LENGTH;
2143
- id[i] = time[i] & 3;
2144
- time[i] >>= 2;
2145
- if (id[i] !== exports.MovementType.up) {
2757
+ for (let j = 0; j < moveSize; j++) {
2758
+ time[j] = this.readInt(replayDataBuffer);
2759
+ id[j] = time[j] & 3;
2760
+ time[j] >>= 2;
2761
+ if (id[j] !== exports.MovementType.Up) {
2146
2762
  if (resultObject.replayVersion >= 5) {
2147
- x[i] = replayDataBuffer.readFloatBE(bufferCounter);
2148
- bufferCounter += this.FLOAT_LENGTH;
2149
- y[i] = replayDataBuffer.readFloatBE(bufferCounter);
2150
- bufferCounter += this.FLOAT_LENGTH;
2763
+ x[j] = this.readFloat(replayDataBuffer);
2764
+ y[j] = this.readFloat(replayDataBuffer);
2151
2765
  }
2152
2766
  else {
2153
- x[i] = replayDataBuffer.readInt16BE(bufferCounter);
2154
- bufferCounter += this.SHORT_LENGTH;
2155
- y[i] = replayDataBuffer.readInt16BE(bufferCounter);
2156
- bufferCounter += this.SHORT_LENGTH;
2767
+ x[j] = this.readShort(replayDataBuffer);
2768
+ y[j] = this.readShort(replayDataBuffer);
2157
2769
  }
2158
2770
  }
2159
2771
  else {
2160
- x[i] = -1;
2161
- y[i] = -1;
2772
+ x[j] = -1;
2773
+ y[j] = -1;
2162
2774
  }
2163
2775
  }
2164
2776
  resultObject.cursorMovement.push(new CursorData({
@@ -2169,25 +2781,23 @@ class ReplayAnalyzer {
2169
2781
  id: id,
2170
2782
  }));
2171
2783
  }
2172
- const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
2173
- bufferCounter += this.INT_LENGTH;
2784
+ }
2785
+ parseHitObjectData(resultObject, replayDataBuffer) {
2786
+ resultObject.hitObjectData.length = 0;
2787
+ const replayObjectLength = this.readInt(replayDataBuffer);
2174
2788
  // Parse result data
2175
2789
  for (let i = 0; i < replayObjectLength; i++) {
2176
2790
  const replayObjectData = {
2177
2791
  accuracy: 0,
2178
2792
  tickset: [],
2179
- result: exports.HitResult.miss,
2793
+ result: osuBase.HitResult.Miss,
2180
2794
  };
2181
- replayObjectData.accuracy =
2182
- replayDataBuffer.readInt16BE(bufferCounter);
2183
- bufferCounter += this.SHORT_LENGTH;
2184
- const len = replayDataBuffer.readInt8(bufferCounter);
2185
- bufferCounter += this.BYTE_LENGTH;
2795
+ replayObjectData.accuracy = this.readShort(replayDataBuffer);
2796
+ const len = this.readByte(replayDataBuffer);
2186
2797
  if (len > 0) {
2187
2798
  const bytes = [];
2188
2799
  for (let j = 0; j < len; j++) {
2189
- bytes.push(replayDataBuffer.readInt8(bufferCounter));
2190
- bufferCounter += this.BYTE_LENGTH;
2800
+ bytes.push(this.readByte(replayDataBuffer));
2191
2801
  }
2192
2802
  // Int/int division in Java; numbers must be truncated to get actual number
2193
2803
  for (let j = 0; j < len * 8; j++) {
@@ -2197,252 +2807,127 @@ class ReplayAnalyzer {
2197
2807
  }
2198
2808
  }
2199
2809
  if (resultObject.replayVersion >= 1) {
2200
- replayObjectData.result =
2201
- replayDataBuffer.readInt8(bufferCounter);
2202
- bufferCounter += this.BYTE_LENGTH;
2810
+ replayObjectData.result = this.readByte(replayDataBuffer);
2203
2811
  }
2204
2812
  resultObject.hitObjectData.push(replayObjectData);
2205
2813
  }
2206
- // Parse max combo, hit results, and accuracy in old replay version
2207
- if (resultObject.replayVersion < 3) {
2208
- const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2209
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2210
- ? this.beatmap.beatmap
2211
- : this.beatmap)?.hitObjects.objects;
2212
- let grantsGekiOrKatu = true;
2213
- for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2214
- // Hit result
2215
- const hitObjectData = resultObject.hitObjectData[i];
2216
- const isNextNewCombo = objects
2217
- ? i + 1 !== objects.length
2218
- ? objects[i + 1].isNewCombo
2219
- : true
2220
- : false;
2221
- switch (hitObjectData.result) {
2222
- case exports.HitResult.miss:
2223
- ++resultObject.accuracy.nmiss;
2224
- grantsGekiOrKatu = false;
2225
- break;
2226
- case exports.HitResult.meh:
2227
- ++resultObject.accuracy.n50;
2228
- grantsGekiOrKatu = false;
2229
- break;
2230
- case exports.HitResult.good:
2231
- ++resultObject.accuracy.n100;
2232
- if (grantsGekiOrKatu && isNextNewCombo) {
2233
- resultObject.hit100k ??= 0;
2234
- ++resultObject.hit100k;
2235
- }
2236
- break;
2237
- case exports.HitResult.great:
2238
- ++resultObject.accuracy.n300;
2239
- if (grantsGekiOrKatu && isNextNewCombo) {
2240
- resultObject.hit300k ??= 0;
2241
- ++resultObject.hit300k;
2242
- }
2243
- break;
2244
- }
2245
- if (isNextNewCombo) {
2246
- grantsGekiOrKatu = true;
2247
- }
2248
- }
2249
- determineRank();
2250
- }
2251
- this.data = new ReplayData(resultObject);
2252
2814
  }
2253
- /**
2254
- * Gets hit error information of the replay.
2255
- *
2256
- * `analyze()` must be called before calling this.
2257
- */
2258
- calculateHitError() {
2259
- if (!this.data || !this.beatmap) {
2260
- return null;
2815
+ parseOldReplayInformation(resultObject) {
2816
+ var _a;
2817
+ // Parse max combo, hit results, and accuracy in old replay version
2818
+ if (resultObject.replayVersion >= 3) {
2819
+ return;
2261
2820
  }
2262
- const hitObjectData = this.data.hitObjectData;
2263
- let positiveCount = 0;
2264
- let negativeCount = 0;
2265
- let positiveTotal = 0;
2266
- let negativeTotal = 0;
2267
- const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2268
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2269
- ? this.beatmap.beatmap
2270
- : this.beatmap;
2271
- const objects = beatmap.hitObjects.objects;
2272
- const stats = new osuBase.MapStats({
2273
- od: beatmap.difficulty.od,
2274
- mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2275
- }).calculate();
2276
- const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2277
- // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2278
- // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2279
- //
2280
- // In such cases, the slider is skipped.
2281
- const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2282
- const accuracies = [];
2283
- for (let i = 0; i < hitObjectData.length; ++i) {
2284
- const v = hitObjectData[i];
2285
- const o = objects[i];
2286
- if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2287
- accuracies.push(0);
2288
- continue;
2289
- }
2290
- const accuracy = v.accuracy;
2291
- if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2292
- accuracies.push(0);
2293
- continue;
2294
- }
2295
- accuracies.push(accuracy);
2296
- if (accuracy >= 0) {
2297
- positiveTotal += accuracy;
2298
- ++positiveCount;
2821
+ const objects = (_a = this.beatmap) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2822
+ let grantsGekiOrKatu = true;
2823
+ resultObject.hit300k = 0;
2824
+ resultObject.hit100k = 0;
2825
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2826
+ // Hit result
2827
+ const hitObjectData = resultObject.hitObjectData[i];
2828
+ const isNextNewCombo = objects
2829
+ ? i + 1 !== objects.length
2830
+ ? objects[i + 1].isNewCombo
2831
+ : true
2832
+ : false;
2833
+ switch (hitObjectData.result) {
2834
+ case osuBase.HitResult.Miss:
2835
+ ++resultObject.accuracy.nmiss;
2836
+ grantsGekiOrKatu = false;
2837
+ break;
2838
+ case osuBase.HitResult.Meh:
2839
+ ++resultObject.accuracy.n50;
2840
+ grantsGekiOrKatu = false;
2841
+ break;
2842
+ case osuBase.HitResult.Good:
2843
+ ++resultObject.accuracy.n100;
2844
+ if (grantsGekiOrKatu && isNextNewCombo) {
2845
+ ++resultObject.hit100k;
2846
+ }
2847
+ break;
2848
+ case osuBase.HitResult.Great:
2849
+ ++resultObject.accuracy.n300;
2850
+ if (grantsGekiOrKatu && isNextNewCombo) {
2851
+ ++resultObject.hit300k;
2852
+ }
2853
+ break;
2299
2854
  }
2300
- else {
2301
- negativeTotal += accuracy;
2302
- ++negativeCount;
2855
+ if (isNextNewCombo) {
2856
+ grantsGekiOrKatu = true;
2303
2857
  }
2304
2858
  }
2305
- return {
2306
- positiveAvg: positiveTotal / positiveCount || 0,
2307
- negativeAvg: negativeTotal / negativeCount || 0,
2308
- unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2309
- };
2859
+ resultObject.rank = this.calculateRank(resultObject);
2310
2860
  }
2311
- /**
2312
- * Converts replay mods to droid mod string.
2313
- */
2314
- convertDroidMods(replayMods) {
2315
- const replayModsConstants = {
2316
- MOD_NOFAIL: "n",
2317
- MOD_EASY: "e",
2318
- MOD_HIDDEN: "h",
2319
- MOD_HARDROCK: "r",
2320
- MOD_DOUBLETIME: "d",
2321
- MOD_HALFTIME: "t",
2322
- MOD_NIGHTCORE: "c",
2323
- MOD_PRECISE: "s",
2324
- MOD_SMALLCIRCLE: "m",
2325
- MOD_SPEEDUP: "b",
2326
- MOD_REALLYEASY: "l",
2327
- MOD_PERFECT: "f",
2328
- MOD_SUDDENDEATH: "u",
2329
- MOD_SCOREV2: "v",
2330
- };
2331
- let modString = "";
2332
- for (const mod of replayMods) {
2333
- for (const property in replayModsConstants) {
2334
- if (!(property in replayModsConstants)) {
2335
- continue;
2336
- }
2337
- if (!mod.includes(property)) {
2338
- continue;
2339
- }
2340
- modString +=
2341
- replayModsConstants[property];
2342
- break;
2343
- }
2861
+ calculateRank(resultObject) {
2862
+ const totalHits = resultObject.accuracy.n300 +
2863
+ resultObject.accuracy.n100 +
2864
+ resultObject.accuracy.n50 +
2865
+ resultObject.accuracy.nmiss;
2866
+ const isHidden = resultObject.convertedMods.has(osuBase.ModHidden) ||
2867
+ resultObject.convertedMods.has(osuBase.ModFlashlight);
2868
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2869
+ switch (true) {
2870
+ case resultObject.accuracy.value() === 1:
2871
+ return isHidden ? "XH" : "X";
2872
+ case hit300Ratio > 0.9 &&
2873
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
2874
+ !resultObject.accuracy.nmiss:
2875
+ return isHidden ? "SH" : "S";
2876
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2877
+ hit300Ratio > 0.9:
2878
+ return "A";
2879
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2880
+ hit300Ratio > 0.8:
2881
+ return "B";
2882
+ case hit300Ratio > 0.6:
2883
+ return "C";
2884
+ default:
2885
+ return "D";
2344
2886
  }
2345
- return modString;
2346
2887
  }
2347
- /**
2348
- * Checks if a play is using 3 fingers.
2349
- *
2350
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2351
- */
2352
- checkFor3Finger() {
2353
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2354
- return;
2355
- }
2356
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap instanceof osuBase.Beatmap
2357
- ? this.beatmap
2358
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2359
- const result = threeFingerChecker.check();
2360
- this.is3Finger = result.is3Finger;
2361
- this.tapPenalty = result.penalty;
2362
- this.hasBeenCheckedFor3Finger = true;
2888
+ constructPlayableBeatmap() {
2889
+ var _a;
2890
+ if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
2891
+ return this.beatmap;
2892
+ }
2893
+ if (!this.beatmap || !this.data) {
2894
+ throw new Error("Beatmap and replay data must be defined.");
2895
+ }
2896
+ const mods = this.data.isReplayV3()
2897
+ ? this.data.convertedMods
2898
+ : (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods;
2899
+ return this.beatmap.createDroidPlayableBeatmap(mods);
2363
2900
  }
2364
- /**
2365
- * Checks if a play is using 2 hands.
2366
- *
2367
- * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2368
- */
2369
- checkFor2Hand() {
2370
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2371
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2372
- !this.data) {
2373
- return;
2374
- }
2375
- const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2376
- const result = twoHandChecker.check();
2377
- this.is2Hand = result.is2Hand;
2378
- this.twoHandedNoteCount = result.twoHandedNoteCount;
2379
- this.hasBeenCheckedFor2Hand = true;
2901
+ readByte(buffer) {
2902
+ const num = buffer.readInt8(this.bufferOffset);
2903
+ this.bufferOffset += 1;
2904
+ return num;
2380
2905
  }
2381
- /**
2382
- * Checks if a play has cheesed sliders.
2383
- *
2384
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2385
- */
2386
- checkForSliderCheesing() {
2387
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2388
- return;
2389
- }
2390
- const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
2391
- ? this.beatmap
2392
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2393
- this.sliderCheesePenalty = sliderCheeseChecker.check();
2394
- this.hasBeenCheckedForSliderCheesing = true;
2906
+ readShort(buffer) {
2907
+ const num = buffer.readInt16BE(this.bufferOffset);
2908
+ this.bufferOffset += 2;
2909
+ return num;
2395
2910
  }
2396
- }
2397
-
2398
- /**
2399
- * Represents a hitobject in an osu!droid replay.
2400
- *
2401
- * Stores information about hitobjects in an osu!droid replay such as hit offset, tickset, and hit result.
2402
- *
2403
- * This is used when analyzing replays using replay analyzer.
2404
- */
2405
- class ReplayObjectData {
2406
- /**
2407
- * For circles, this is the offset at which the circle was hit. If the hit accuracy is 10000, it means the circle was tapped too late ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/HitCircle.java#L298-L306)).
2408
- *
2409
- * For sliders, this is the offset at which the slider head was hit. For
2410
- * sliderbreaks, the accuracy would be `Math.floor(<hit window 50>ms) + 13ms` ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821)).
2411
- *
2412
- * For spinners, this is the total amount at which the spinner was spinned:
2413
- * ```js
2414
- * const rotations = Math.floor(data.accuracy / 4);
2415
- * ```
2416
- * The remainder of the division denotes the hit result of the spinner:
2417
- * - `HitResult.great`: 3
2418
- * - `HitResult.good`: 2
2419
- * - `HitResult.meh`: 1
2420
- * - `HitResult.miss`: 0
2421
- */
2422
- accuracy;
2423
- /**
2424
- * The tickset of the hitobject.
2425
- *
2426
- * This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
2427
- */
2428
- tickset;
2429
- /**
2430
- * The bitwise hit result of the hitobject.
2431
- */
2432
- result;
2433
- constructor(values) {
2434
- this.accuracy = values.accuracy;
2435
- this.tickset = values.tickset;
2436
- this.result = values.result;
2911
+ readInt(buffer) {
2912
+ const num = buffer.readInt32BE(this.bufferOffset);
2913
+ this.bufferOffset += 4;
2914
+ return num;
2915
+ }
2916
+ readFloat(buffer) {
2917
+ const num = buffer.readFloatBE(this.bufferOffset);
2918
+ this.bufferOffset += 4;
2919
+ return num;
2437
2920
  }
2438
2921
  }
2439
2922
 
2440
2923
  exports.CursorData = CursorData;
2441
2924
  exports.CursorOccurrence = CursorOccurrence;
2442
2925
  exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
2926
+ exports.RebalanceSliderCheeseChecker = RebalanceSliderCheeseChecker;
2927
+ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2443
2928
  exports.ReplayAnalyzer = ReplayAnalyzer;
2444
2929
  exports.ReplayData = ReplayData;
2445
- exports.ReplayObjectData = ReplayObjectData;
2930
+ exports.ReplayV3Data = ReplayV3Data;
2446
2931
  exports.SliderCheeseChecker = SliderCheeseChecker;
2447
2932
  exports.ThreeFingerChecker = ThreeFingerChecker;
2448
2933
  exports.TwoHandChecker = TwoHandChecker;