@rian8337/osu-droid-replay-analyzer 4.0.0-beta.4 → 4.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -51,18 +51,6 @@ exports.MovementType = void 0;
51
51
  * Represents a cursor's occurrence.
52
52
  */
53
53
  class CursorOccurrence {
54
- /**
55
- * The time of this occurrence.
56
- */
57
- time;
58
- /**
59
- * The position of the occurrence.
60
- */
61
- position;
62
- /**
63
- * The movement ID of the occurrence.
64
- */
65
- id;
66
54
  constructor(time, x, y, id) {
67
55
  this.time = time;
68
56
  this.position = new osuBase.Vector2(x, y);
@@ -125,7 +113,8 @@ class CursorOccurrenceGroup {
125
113
  * The time at which this cursor occurrence group ends.
126
114
  */
127
115
  get endTime() {
128
- return this._up?.time ?? this._moves.at(-1)?.time ?? this._down.time;
116
+ var _a, _b, _c, _d;
117
+ return (_d = (_b = (_a = this._up) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : (_c = this._moves.at(-1)) === null || _c === void 0 ? void 0 : _c.time) !== null && _d !== void 0 ? _d : this._down.time;
129
118
  }
130
119
  /**
131
120
  * The duration this cursor occurrence group is active for.
@@ -145,20 +134,6 @@ class CursorOccurrenceGroup {
145
134
  }
146
135
  return cursors;
147
136
  }
148
- /**
149
- * The cursor occurrence of movement type `movementType.DOWN`.
150
- */
151
- _down;
152
- /**
153
- * The cursor occurrences of movement type `movementType.MOVE`.
154
- */
155
- _moves;
156
- /**
157
- * The cursor occurrence of movement type `movementType.UP`.
158
- *
159
- * May not exist, such as when the player holds their cursor until the end of a beatmap.
160
- */
161
- _up;
162
137
  constructor(down, moves, up) {
163
138
  this._down = down;
164
139
  this._moves = moves;
@@ -182,13 +157,14 @@ class CursorOccurrenceGroup {
182
157
  * @returns The cursor occurrence at the given time, `null` if not found.
183
158
  */
184
159
  cursorAt(time) {
160
+ var _a;
185
161
  if (!this.isActiveAt(time)) {
186
162
  return null;
187
163
  }
188
164
  if (this._down.time === time) {
189
165
  return this._down;
190
166
  }
191
- if (this._up?.time === time) {
167
+ if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
192
168
  return this._up;
193
169
  }
194
170
  let l = 0;
@@ -218,17 +194,14 @@ class CursorOccurrenceGroup {
218
194
  * This is used when analyzing replays using replay analyzer.
219
195
  */
220
196
  class CursorData {
221
- /**
222
- * The occurrence groups of this cursor instance.
223
- */
224
- occurrenceGroups = [];
225
197
  /**
226
198
  * The time at which the first occurrence of this cursor instance occurs.
227
199
  *
228
200
  * Will return `null` if there are no occurrences.
229
201
  */
230
202
  get earliestOccurrenceTime() {
231
- return this.occurrenceGroups.at(0)?.startTime ?? null;
203
+ var _a, _b;
204
+ return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
232
205
  }
233
206
  /**
234
207
  * The time at which the latest occurrence of this cursor instance occurs.
@@ -236,7 +209,8 @@ class CursorData {
236
209
  * Will return `null` if there are no occurrences.
237
210
  */
238
211
  get latestOccurrenceTime() {
239
- return this.occurrenceGroups.at(-1)?.endTime ?? null;
212
+ var _a, _b;
213
+ return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
240
214
  }
241
215
  /**
242
216
  * The amount of cursor occurrences of this cursor instance.
@@ -263,6 +237,10 @@ class CursorData {
263
237
  return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
264
238
  }
265
239
  constructor(values) {
240
+ /**
241
+ * The occurrence groups of this cursor instance.
242
+ */
243
+ this.occurrenceGroups = [];
266
244
  let downOccurrence = null;
267
245
  let moveOccurrences = [];
268
246
  for (let i = 0; i < values.size; ++i) {
@@ -313,165 +291,43 @@ exports.HitResult = void 0;
313
291
  })(exports.HitResult || (exports.HitResult = {}));
314
292
 
315
293
  /**
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;
362
- }
363
- }
364
-
365
- /**
366
- * Utility to check whether or not a beatmap is three-fingered.
294
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
367
295
  */
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;
394
- /**
395
- * The threshold for the amount of cursors that are assumed to be pressed
396
- * by a single finger.
397
- */
398
- cursorDistancingCountThreshold = 10;
399
- /**
400
- * The threshold for the time difference of cursors that are assumed to be pressed
401
- * by a single finger, in milliseconds.
402
- */
403
- cursorDistancingTimeThreshold = 1000;
404
- /**
405
- * The amount of notes that has a tap strain exceeding `strainThreshold`.
406
- */
407
- strainNoteCount;
408
- /**
409
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
410
- *
411
- * Increasing this number will increase detection accuracy, however
412
- * it also increases the chance of falsely flagged plays.
413
- */
414
- threeFingerRatioThreshold = 0.01;
415
- /**
416
- * Extended sections of the beatmap for drag detection.
417
- */
418
- beatmapSections = [];
419
- /**
420
- * This threshold is used to filter out accidental taps.
421
- *
422
- * Increasing this number makes the filtration more sensitive, however it
423
- * will also increase the chance of 3-fingered plays getting out from
424
- * being flagged.
425
- */
426
- accidentalTapThreshold = 400;
427
- /**
428
- * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
429
- */
430
- hitWindow;
431
- /**
432
- * A reprocessed break points to match right on object time.
433
- *
434
- * This is used to increase detection accuracy since break points do not start right at the
435
- * start of the hitobject before it and do not end right at the first hitobject after it.
436
- */
437
- breakPointAccurateTimes = [];
438
- /**
439
- * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
440
- *
441
- * Each index represents the cursor index.
442
- */
443
- downCursorInstances = [];
444
- /**
445
- * Nerf factors from all sections that were three-fingered.
446
- */
447
- nerfFactors = [];
448
- /**
449
- * Whether this score uses the Precise mod.
450
- */
451
- isPrecise;
296
+ class RebalanceThreeFingerChecker {
452
297
  /**
453
298
  * @param beatmap The beatmap to analyze.
454
299
  * @param data The data of the replay.
455
300
  * @param difficultyAttributes The difficulty attributes of the beatmap.
456
301
  */
457
302
  constructor(beatmap, data, difficultyAttributes) {
303
+ /**
304
+ * Extended sections of the beatmap for drag detection.
305
+ */
306
+ this.beatmapSections = [];
307
+ /**
308
+ * A reprocessed break points to match right on object time.
309
+ *
310
+ * This is used to increase detection accuracy since break points do not start right at the
311
+ * start of the hitobject before it and do not end right at the first hitobject after it.
312
+ */
313
+ this.breakPointAccurateTimes = [];
314
+ /**
315
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
316
+ *
317
+ * Each index represents the cursor index.
318
+ */
319
+ this.downCursorInstances = [];
320
+ /**
321
+ * Nerf factors from all sections that were three-fingered.
322
+ */
323
+ this.nerfFactors = [];
458
324
  this.beatmap = beatmap;
459
325
  this.data = data;
460
326
  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;
327
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
328
+ this.hitWindow = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise)
329
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
330
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od);
475
331
  }
476
332
  /**
477
333
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -492,7 +348,8 @@ class ThreeFingerChecker {
492
348
  * nerf factor, taking beatmap difficulty into account.
493
349
  */
494
350
  check() {
495
- if (this.strainNoteCount === 0) {
351
+ if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
352
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
496
353
  return { is3Finger: false, penalty: 1 };
497
354
  }
498
355
  this.getAccurateBreakPoints();
@@ -501,11 +358,6 @@ class ThreeFingerChecker {
501
358
  return { is3Finger: false, penalty: 1 };
502
359
  }
503
360
  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
361
  this.calculateNerfFactors();
510
362
  const finalPenalty = this.calculateFinalPenalty();
511
363
  return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
@@ -514,42 +366,32 @@ class ThreeFingerChecker {
514
366
  * Generates a new set of "accurate break points".
515
367
  *
516
368
  * 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.
369
+ * end of the hitobject before it and do not end right at the first hitobject after it.
518
370
  */
519
371
  getAccurateBreakPoints() {
520
- const { objects } = this.beatmap.hitObjects;
372
+ const objects = this.beatmap.hitObjects.objects;
521
373
  const objectData = this.data.hitObjectData;
522
374
  for (const breakPoint of this.beatmap.events.breaks) {
523
375
  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);
376
+ const objectBefore = objects[beforeIndex];
377
+ const objectBeforeData = objectData[beforeIndex];
378
+ let timeBefore = objectBefore.endTime;
379
+ if (objectBefore instanceof osuBase.Circle) {
380
+ if (objectBeforeData.result !== exports.HitResult.miss) {
381
+ timeBefore += objectBeforeData.accuracy;
382
+ }
383
+ else {
384
+ timeBefore += this.hitWindow.mehWindow;
385
+ }
536
386
  }
537
- timeBefore += beforeIndexHitWindowLength;
538
387
  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);
388
+ const objectAfter = objects[afterIndex];
389
+ const objectAfterData = objectData[afterIndex];
390
+ let timeAfter = objectAfter.startTime;
391
+ if (objectAfter instanceof osuBase.Circle &&
392
+ objectAfterData.result !== exports.HitResult.miss) {
393
+ timeAfter += objectAfterData.accuracy;
551
394
  }
552
- timeAfter += afterIndexHitWindowLength;
553
395
  this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
554
396
  startTime: timeBefore,
555
397
  endTime: timeAfter,
@@ -562,48 +404,51 @@ class ThreeFingerChecker {
562
404
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
563
405
  */
564
406
  filterCursorInstances() {
565
- const { objects } = this.beatmap.hitObjects;
407
+ const objects = this.beatmap.hitObjects.objects;
566
408
  const objectData = this.data.hitObjectData;
567
409
  const firstObjectResult = objectData[0].result;
568
410
  const lastObjectResult = objectData.at(-1).result;
411
+ const firstObject = objects[0];
412
+ const lastObject = objects.at(-1);
569
413
  // 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) {
414
+ let firstObjectHitWindow = this.hitWindow.mehWindow;
415
+ if (firstObject instanceof osuBase.Circle) {
572
416
  switch (firstObjectResult) {
573
417
  case exports.HitResult.great:
574
- firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
418
+ firstObjectHitWindow = this.hitWindow.greatWindow;
575
419
  break;
576
420
  case exports.HitResult.good:
577
- firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
421
+ firstObjectHitWindow = this.hitWindow.okWindow;
578
422
  break;
579
423
  default:
580
- firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
424
+ firstObjectHitWindow = this.hitWindow.mehWindow;
581
425
  }
582
426
  }
583
427
  // 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) {
428
+ let lastObjectHitWindow = this.hitWindow.mehWindow;
429
+ if (lastObject instanceof osuBase.Circle) {
586
430
  switch (lastObjectResult) {
587
431
  case exports.HitResult.great:
588
- lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
432
+ lastObjectHitWindow = this.hitWindow.greatWindow;
589
433
  break;
590
434
  case exports.HitResult.good:
591
- lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
435
+ lastObjectHitWindow = this.hitWindow.okWindow;
592
436
  break;
593
437
  default:
594
- lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
438
+ lastObjectHitWindow = this.hitWindow.mehWindow;
595
439
  }
596
440
  }
441
+ else if (lastObject instanceof osuBase.Slider) {
442
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
443
+ }
597
444
  // These hit time uses hit window length as threshold.
598
445
  // This is because cursors aren't recorded exactly at hit time,
599
446
  // 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];
447
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
448
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
449
+ for (const cursorInstance of this.data.cursorMovement) {
604
450
  const validOccurrences = [];
605
- for (let j = 0; j < cursorInstance.occurrenceGroups.length; ++j) {
606
- const group = cursorInstance.occurrenceGroups[j];
451
+ for (const group of cursorInstance.occurrenceGroups) {
607
452
  if (group.startTime < firstObjectHitTime) {
608
453
  continue;
609
454
  }
@@ -624,437 +469,742 @@ class ThreeFingerChecker {
624
469
  * detect dragged sections and improve detection speed.
625
470
  */
626
471
  getBeatmapSections() {
472
+ const beatmapObjects = this.beatmap.hitObjects.objects;
473
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
474
+ // This intentionally starts from 1 because we need to look at the previous cursor.
475
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
476
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
627
477
  for (const section of this.difficultyAttributes
628
478
  .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;
644
- }
645
- }
646
- /**
647
- * Checks if a section is dragged and returns the index of the drag finger.
648
- *
649
- * If the section is not dragged, -1 will be returned.
650
- *
651
- * @param section The section to check.
652
- */
653
- 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);
669
- }
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);
685
- }
686
- }
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 = [];
693
- 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;
479
+ const objects = [];
480
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
481
+ const object = beatmapObjects[i];
482
+ const objectData = this.data.hitObjectData[i];
483
+ objects.push({
484
+ object: object,
485
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
486
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
487
+ });
708
488
  }
709
- cursorIndexes.push(i);
489
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
710
490
  }
711
- return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
712
491
  }
713
492
  /**
714
- * Finds the drag index of the section.
493
+ * Obtains the index of the cursor that aimed the object at the nearest time.
715
494
  *
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) {
495
+ * @param object The object to obtain the index for.
496
+ * @param objectData The hit data of the object.
497
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
498
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
499
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
500
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
501
+ */
502
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
503
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
504
+ return -1;
505
+ }
506
+ // Check for sliderbreaks and treat them as misses.
507
+ if (object instanceof osuBase.Slider &&
508
+ (-this.hitWindow.mehWindow > objectData.accuracy ||
509
+ objectData.accuracy >
510
+ Math.min(this.hitWindow.mehWindow, object.duration))) {
511
+ return -1;
512
+ }
513
+ const hitTime = object.startTime + objectData.accuracy;
514
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
515
+ // We are maintaining the closest distance to the object.
516
+ // This is because the radius that is calculated is using an estimation.
517
+ // As such, it does not reflect the actual object radius in gameplay.
518
+ let closestDistance = Number.POSITIVE_INFINITY;
519
+ let nearestCursorIndex = -1;
520
+ // Observe the cursor position at the object's hit time.
521
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
522
+ const cursorData = this.data.cursorMovement[i];
523
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
524
+ const cursorGroup = cursorData.occurrenceGroups[j];
525
+ if (cursorGroup.endTime < hitTime) {
526
+ // Reset cursor index pointer.
527
+ cursorIndices[i] = 1;
744
528
  continue;
745
529
  }
746
- const cursorData = this.data.cursorMovement[cursorIndexes[j]];
747
- const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
748
- if (!cursorGroup) {
749
- continue;
530
+ if (cursorGroup.startTime > hitTime) {
531
+ break;
750
532
  }
751
533
  const cursors = cursorGroup.allOccurrences;
752
- for (let k = 1; k < cursors.length; ++k) {
534
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
753
535
  const cursor = cursors[k];
754
536
  const prevCursor = cursors[k - 1];
755
- if (prevCursor.time < object.startTime - hitWindow50) {
756
- continue;
757
- }
758
- if (prevCursor.time > object.startTime + hitWindow50) {
537
+ // Cursor is past the object's hit time.
538
+ if (prevCursor.time > hitTime) {
759
539
  break;
760
540
  }
761
- let isInObject = false;
541
+ // Cursor is before the object's hit time.
542
+ if (hitTime > cursor.time) {
543
+ continue;
544
+ }
545
+ let distance;
546
+ const currentPosition = this.getCursorPosition(cursor);
547
+ const prevPosition = this.getCursorPosition(prevCursor);
762
548
  switch (cursor.id) {
763
549
  case exports.MovementType.up:
764
- isInObject =
765
- prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
550
+ distance = prevPosition.getDistance(objectPosition);
766
551
  break;
767
- case exports.MovementType.move:
552
+ case exports.MovementType.move: {
768
553
  // 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
- }
554
+ const t = (hitTime - prevCursor.time) /
555
+ (cursor.time - prevCursor.time);
556
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
557
+ distance =
558
+ objectPosition.getDistance(cursorPosition);
559
+ break;
560
+ }
561
+ case exports.MovementType.down:
562
+ continue;
778
563
  }
779
- if (!isInObject) {
780
- cursorIndexes[j] = -1;
564
+ if (closestDistance > distance) {
565
+ closestDistance = distance;
566
+ nearestCursorIndex = i;
781
567
  }
782
568
  }
569
+ // Reset cursor index pointer on end of group.
570
+ if (cursorIndices[i] === cursors.length) {
571
+ cursorIndices[i] = 1;
572
+ }
573
+ break;
783
574
  }
575
+ // The previous object may still be hit with the same cursor group or cursor index.
576
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
577
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
784
578
  }
785
- return cursorIndexes.find((v) => v !== -1) ?? -1;
579
+ return nearestCursorIndex;
786
580
  }
787
581
  /**
788
- * Attempts to prevent accidental taps from being flagged.
582
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
789
583
  *
790
- * This detection will filter cursors that don't hit
791
- * any object in beatmap sections, thus eliminating any
792
- * unnecessary taps.
793
- */
794
- preventAccidentalTaps() {
795
- let filledCursorAmount = this.downCursorInstances.filter((v) => v.length > 0).length;
796
- if (filledCursorAmount <= 3) {
797
- return;
798
- }
799
- const { objects } = this.beatmap.hitObjects;
800
- const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.length, 0);
584
+ * @param object The object to obtain the index for.
585
+ * @param objectData The hit data of the object.
586
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
587
+ * @param excludedIndices The cursor indices that should not be checked.
588
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
589
+ */
590
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
591
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
592
+ return -1;
593
+ }
594
+ // Check for sliderbreaks and treat them as misses.
595
+ if (object instanceof osuBase.Slider &&
596
+ (-this.hitWindow.mehWindow > objectData.accuracy ||
597
+ objectData.accuracy >
598
+ Math.min(this.hitWindow.mehWindow, object.duration))) {
599
+ return -1;
600
+ }
601
+ const hitTime = object.startTime + objectData.accuracy;
602
+ let nearestCursorInstanceIndex = -1;
603
+ let nearestTime = Number.POSITIVE_INFINITY;
801
604
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
802
- if (filledCursorAmount <= 3) {
803
- break;
605
+ const cursors = this.downCursorInstances[i];
606
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
607
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
608
+ const cursor = cursors[j];
609
+ if (cursor.time > hitTime) {
610
+ break;
611
+ }
612
+ cursorNearestTime = hitTime - cursor.time;
804
613
  }
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;
614
+ if (cursorNearestTime < nearestTime) {
615
+ nearestCursorInstanceIndex = i;
616
+ nearestTime = cursorNearestTime;
813
617
  }
814
- this.downCursorInstances[i] = cursorInstances;
815
618
  }
619
+ return nearestCursorInstanceIndex;
816
620
  }
817
621
  /**
818
622
  * Creates nerf factors by scanning through objects.
819
- *
820
- * This check will ignore all objects with speed strain below `strainThreshold`.
821
623
  */
822
624
  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
625
  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) {
626
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
627
+ for (const object of beatmapSection.objects) {
628
+ if (object.pressingCursorInstanceIndex === -1) {
844
629
  continue;
845
630
  }
846
- const cursors = this.downCursorInstances[i];
847
- let amount = 0;
848
- for (let j = 0; j < cursors.length; ++j) {
849
- if (cursors[j].time >= startTime &&
850
- cursors[j].time <= endTime) {
851
- ++amount;
852
- cursorVectorTimes.push({
853
- vector: new osuBase.Vector2(cursors[j].position.x, cursors[j].position.y),
854
- time: cursors[j].time,
855
- });
856
- }
857
- }
858
- cursorAmounts.push(amount);
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;
631
+ if (object.aimingCursorInstanceIndex < 3) {
632
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
633
+ switch (object.pressingCursorInstanceIndex) {
634
+ case 0:
635
+ case 1:
636
+ case 2:
637
+ break;
638
+ default:
639
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
640
+ break;
887
641
  }
888
- similarPresses[pressIndex].vector = cursorVectorTime.vector;
889
- similarPresses[pressIndex].lastTime = cursorVectorTime.time;
890
- ++similarPresses[pressIndex].count;
891
642
  }
892
643
  else {
893
- similarPresses.push({
894
- vector: cursorVectorTime.vector,
895
- count: 1,
896
- lastTime: cursorVectorTime.time,
897
- });
644
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
645
+ switch (object.pressingCursorInstanceIndex) {
646
+ case 0:
647
+ case 1:
648
+ break;
649
+ default:
650
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
651
+ break;
652
+ }
898
653
  }
899
654
  }
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
- });
655
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
656
+ if (threeFingerCursorCount === 0) {
657
+ continue;
939
658
  }
659
+ const sectionObjectCount = beatmapSection.objects.length;
660
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
661
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
662
+ // Finger factor applies more penalty if more fingers were used.
663
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
664
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
665
+ // Length factor applies more penalty if there are more 3-fingered object.
666
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
667
+ this.nerfFactors.push({
668
+ strainFactor: strainFactor,
669
+ fingerFactor: fingerFactor,
670
+ lengthFactor: lengthFactor,
671
+ });
940
672
  }
941
673
  }
942
674
  /**
943
675
  * Calculates the final penalty.
944
676
  */
945
677
  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));
678
+ return this.nerfFactors.reduce((a, n) => a +
679
+ 0.015 *
680
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
681
+ }
682
+ getCursorPosition(cursor) {
683
+ if (this.isHardRock) {
684
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
685
+ }
686
+ return cursor.position;
950
687
  }
951
688
  }
952
689
 
690
+ /******************************************************************************
691
+ Copyright (c) Microsoft Corporation.
692
+
693
+ Permission to use, copy, modify, and/or distribute this software for any
694
+ purpose with or without fee is hereby granted.
695
+
696
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
697
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
698
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
699
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
700
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
701
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
702
+ PERFORMANCE OF THIS SOFTWARE.
703
+ ***************************************************************************** */
704
+ /* global Reflect, Promise, SuppressedError, Symbol */
705
+
706
+
707
+ function __awaiter(thisArg, _arguments, P, generator) {
708
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
709
+ return new (P || (P = Promise))(function (resolve, reject) {
710
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
711
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
712
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
713
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
714
+ });
715
+ }
716
+
717
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
718
+ var e = new Error(message);
719
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
720
+ };
721
+
953
722
  /**
954
- * Contains information about which cursor index hits a hitobject.
723
+ * Represents a replay data in an osu!droid replay version 1 and 2.
724
+ *
725
+ * Stores generic information about an osu!droid replay.
726
+ *
727
+ * This is used when analyzing replays using replay analyzer.
955
728
  */
956
- class IndexedHitObject {
957
- /**
958
- * The cursor index that hits the hitobject.
959
- *
960
- * If -1, the detection was unable to find any cursor that attempted to hit
961
- * the hitobject or it did not meet the criteria for detection.
962
- */
963
- cursorIndex;
964
- /**
965
- * The group index of the cursor within the cursor index that hits the hitobject.
966
- *
967
- * If -1, the detection was unable to find any cursor that attempted to hit
968
- * the hitobject or it did not meet the criteria for detection.
969
- */
970
- groupIndex;
971
- /**
972
- * The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
973
- *
974
- * If -1, the detection was unable to find any cursor that attempted to hit
975
- * the hitobject or it did not meet the criteria for detection.
976
- */
977
- occurrenceIndex;
978
- /**
979
- * The angle of the movement of the cursor towards the next hitobject.
980
- */
981
- angle;
982
- /**
983
- * If this is a slider, whether the slider was cheesed.
984
- */
985
- sliderCheesed = false;
986
- /**
987
- * The underlying difficulty hitobject.
988
- */
989
- object;
729
+ class ReplayData {
730
+ constructor(values) {
731
+ this.replayVersion = values.replayVersion;
732
+ this.folderName = values.folderName;
733
+ this.fileName = values.fileName;
734
+ this.hash = values.hash;
735
+ this.accuracy = values.accuracy;
736
+ this.rank = values.rank;
737
+ this.hit300k = values.hit300k;
738
+ this.hit100k = values.hit100k;
739
+ this.cursorMovement = values.cursorMovement;
740
+ this.hitObjectData = values.hitObjectData;
741
+ }
990
742
  /**
991
- * The position of the cursor at the end of this hitobject.
992
- *
993
- * Will be altered during detection.
743
+ * Whether the replay's version is 3 or later.
994
744
  */
995
- endCursorPosition;
745
+ isReplayV3() {
746
+ return this.replayVersion >= 3;
747
+ }
996
748
  /**
997
- * Whether the hitobject is likely two-handed.
749
+ * Whether the replay's version is 4 or later.
998
750
  */
999
- is2Handed;
751
+ isReplayV4() {
752
+ return this.replayVersion >= 4;
753
+ }
1000
754
  /**
1001
- * @param object The underlying difficulty hitobject.
1002
- * @param cursorIndex The cursor index that moves towards the hitobject.
1003
- * @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
1004
- * @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
1005
- * @param angle The angle of the movement of the cursor that moves towards the hitobject.
755
+ * Whether the replay's version is 5 or later.
1006
756
  */
1007
- constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
1008
- this.object = object;
1009
- this.cursorIndex = cursorIndex;
1010
- this.groupIndex = groupIndex;
1011
- this.occurrenceIndex = occurrenceIndex;
1012
- this.angle = angle;
1013
- this.is2Handed = is2Handed;
1014
- this.endCursorPosition = this.object.object.getStackedEndPosition(osuBase.Modes.droid);
757
+ isReplayV5() {
758
+ return this.replayVersion >= 5;
1015
759
  }
1016
760
  }
1017
761
 
1018
- // import { writeFileSync } from "fs";
1019
762
  /**
1020
- * Utility to check whether or not a beatmap is two-handed.
763
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
1021
764
  */
1022
- class TwoHandChecker {
1023
- /**
1024
- * The difficulty calculator that is being analyzed.
1025
- */
1026
- calculator;
1027
- /**
1028
- * The data of the replay.
1029
- */
1030
- data;
765
+ class ThreeFingerChecker {
1031
766
  /**
1032
- * The hitobjects of the beatmap that have been assigned with their respective cursor index.
767
+ * @param beatmap The beatmap to analyze.
768
+ * @param data The data of the replay.
769
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
1033
770
  */
1034
- indexedHitObjects = [];
771
+ constructor(beatmap, data, difficultyAttributes) {
772
+ /**
773
+ * Extended sections of the beatmap for drag detection.
774
+ */
775
+ this.beatmapSections = [];
776
+ /**
777
+ * A reprocessed break points to match right on object time.
778
+ *
779
+ * This is used to increase detection accuracy since break points do not start right at the
780
+ * start of the hitobject before it and do not end right at the first hitobject after it.
781
+ */
782
+ this.breakPointAccurateTimes = [];
783
+ /**
784
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
785
+ *
786
+ * Each index represents the cursor index.
787
+ */
788
+ this.downCursorInstances = [];
789
+ /**
790
+ * Nerf factors from all sections that were three-fingered.
791
+ */
792
+ this.nerfFactors = [];
793
+ this.beatmap = beatmap;
794
+ this.data = data;
795
+ this.difficultyAttributes = difficultyAttributes;
796
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
797
+ this.hitWindow = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise)
798
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
799
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od);
800
+ }
1035
801
  /**
1036
- * The osu!droid hitwindow of the analyzed beatmap.
802
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
803
+ *
804
+ * @param difficultyAttributes The difficulty attributes of the beatmap.
1037
805
  */
1038
- hitWindow;
806
+ static isEligibleToDetect(difficultyAttributes) {
807
+ return difficultyAttributes.possibleThreeFingeredSections.length > 0;
808
+ }
1039
809
  /**
1040
- * The 50 osu!droid hit window of the analyzed beatmap.
1041
- */
1042
- hitWindow50;
1043
- // private csvString: string;
810
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
811
+ *
812
+ * The beatmap will be separated into sections and each section will be determined
813
+ * whether or not it is dragged.
814
+ *
815
+ * After that, each section will be assigned a nerf factor based on whether or not
816
+ * the section is 3-fingered. These nerf factors will be summed up into a final
817
+ * nerf factor, taking beatmap difficulty into account.
818
+ */
819
+ check() {
820
+ if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
821
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
822
+ return { is3Finger: false, penalty: 1 };
823
+ }
824
+ this.getAccurateBreakPoints();
825
+ this.filterCursorInstances();
826
+ if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
827
+ return { is3Finger: false, penalty: 1 };
828
+ }
829
+ this.getBeatmapSections();
830
+ this.calculateNerfFactors();
831
+ const finalPenalty = this.calculateFinalPenalty();
832
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
833
+ }
834
+ /**
835
+ * Generates a new set of "accurate break points".
836
+ *
837
+ * This is done to increase detection accuracy since break points do not start right at the
838
+ * end of the hitobject before it and do not end right at the first hitobject after it.
839
+ */
840
+ getAccurateBreakPoints() {
841
+ const objects = this.beatmap.hitObjects.objects;
842
+ const objectData = this.data.hitObjectData;
843
+ for (const breakPoint of this.beatmap.events.breaks) {
844
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
845
+ const objectBefore = objects[beforeIndex];
846
+ const objectBeforeData = objectData[beforeIndex];
847
+ let timeBefore = objectBefore.endTime;
848
+ if (objectBefore instanceof osuBase.Circle) {
849
+ if (objectBeforeData.result !== exports.HitResult.miss) {
850
+ timeBefore += objectBeforeData.accuracy;
851
+ }
852
+ else {
853
+ timeBefore += this.hitWindow.mehWindow;
854
+ }
855
+ }
856
+ const afterIndex = beforeIndex + 1;
857
+ const objectAfter = objects[afterIndex];
858
+ const objectAfterData = objectData[afterIndex];
859
+ let timeAfter = objectAfter.startTime;
860
+ if (objectAfter instanceof osuBase.Circle &&
861
+ objectAfterData.result !== exports.HitResult.miss) {
862
+ timeAfter += objectAfterData.accuracy;
863
+ }
864
+ this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
865
+ startTime: timeBefore,
866
+ endTime: timeAfter,
867
+ }));
868
+ }
869
+ }
870
+ /**
871
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
872
+ *
873
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
874
+ */
875
+ filterCursorInstances() {
876
+ const objects = this.beatmap.hitObjects.objects;
877
+ const objectData = this.data.hitObjectData;
878
+ const firstObjectResult = objectData[0].result;
879
+ const lastObjectResult = objectData.at(-1).result;
880
+ const firstObject = objects[0];
881
+ const lastObject = objects.at(-1);
882
+ // For sliders, automatically set hit window length to be as lenient as possible.
883
+ let firstObjectHitWindow = this.hitWindow.mehWindow;
884
+ if (firstObject instanceof osuBase.Circle) {
885
+ switch (firstObjectResult) {
886
+ case exports.HitResult.great:
887
+ firstObjectHitWindow = this.hitWindow.greatWindow;
888
+ break;
889
+ case exports.HitResult.good:
890
+ firstObjectHitWindow = this.hitWindow.okWindow;
891
+ break;
892
+ default:
893
+ firstObjectHitWindow = this.hitWindow.mehWindow;
894
+ }
895
+ }
896
+ // For sliders, automatically set hit window length to be as lenient as possible.
897
+ let lastObjectHitWindow = this.hitWindow.mehWindow;
898
+ if (lastObject instanceof osuBase.Circle) {
899
+ switch (lastObjectResult) {
900
+ case exports.HitResult.great:
901
+ lastObjectHitWindow = this.hitWindow.greatWindow;
902
+ break;
903
+ case exports.HitResult.good:
904
+ lastObjectHitWindow = this.hitWindow.okWindow;
905
+ break;
906
+ default:
907
+ lastObjectHitWindow = this.hitWindow.mehWindow;
908
+ }
909
+ }
910
+ else if (lastObject instanceof osuBase.Slider) {
911
+ lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
912
+ }
913
+ // These hit time uses hit window length as threshold.
914
+ // This is because cursors aren't recorded exactly at hit time,
915
+ // probably due to the game's behavior.
916
+ const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
917
+ const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
918
+ for (const cursorInstance of this.data.cursorMovement) {
919
+ const validOccurrences = [];
920
+ for (const group of cursorInstance.occurrenceGroups) {
921
+ if (group.startTime < firstObjectHitTime) {
922
+ continue;
923
+ }
924
+ if (group.startTime > lastObjectHitTime) {
925
+ break;
926
+ }
927
+ if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
928
+ group.endTime <= v.endTime)) {
929
+ continue;
930
+ }
931
+ validOccurrences.push(group.down);
932
+ }
933
+ this.downCursorInstances.push(validOccurrences);
934
+ }
935
+ }
936
+ /**
937
+ * Divides the beatmap into sections, which will be used to
938
+ * detect dragged sections and improve detection speed.
939
+ */
940
+ getBeatmapSections() {
941
+ const beatmapObjects = this.beatmap.hitObjects.objects;
942
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
943
+ // This intentionally starts from 1 because we need to look at the previous cursor.
944
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
945
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
946
+ for (const section of this.difficultyAttributes
947
+ .possibleThreeFingeredSections) {
948
+ const objects = [];
949
+ for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
950
+ const object = beatmapObjects[i];
951
+ const objectData = this.data.hitObjectData[i];
952
+ objects.push({
953
+ object: object,
954
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
955
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
956
+ });
957
+ }
958
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
959
+ }
960
+ }
961
+ /**
962
+ * Obtains the index of the cursor that aimed the object at the nearest time.
963
+ *
964
+ * @param object The object to obtain the index for.
965
+ * @param objectData The hit data of the object.
966
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
967
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
968
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
969
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
970
+ */
971
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
972
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
973
+ return -1;
974
+ }
975
+ // Check for sliderbreaks and treat them as misses.
976
+ if (object instanceof osuBase.Slider &&
977
+ (-this.hitWindow.mehWindow > objectData.accuracy ||
978
+ objectData.accuracy >
979
+ Math.min(this.hitWindow.mehWindow, object.duration))) {
980
+ return -1;
981
+ }
982
+ const hitTime = object.startTime + objectData.accuracy;
983
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
984
+ // We are maintaining the closest distance to the object.
985
+ // This is because the radius that is calculated is using an estimation.
986
+ // As such, it does not reflect the actual object radius in gameplay.
987
+ let closestDistance = Number.POSITIVE_INFINITY;
988
+ let nearestCursorIndex = -1;
989
+ // Observe the cursor position at the object's hit time.
990
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
991
+ const cursorData = this.data.cursorMovement[i];
992
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
993
+ const cursorGroup = cursorData.occurrenceGroups[j];
994
+ if (cursorGroup.endTime < hitTime) {
995
+ // Reset cursor index pointer.
996
+ cursorIndices[i] = 1;
997
+ continue;
998
+ }
999
+ if (cursorGroup.startTime > hitTime) {
1000
+ break;
1001
+ }
1002
+ const cursors = cursorGroup.allOccurrences;
1003
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
1004
+ const cursor = cursors[k];
1005
+ const prevCursor = cursors[k - 1];
1006
+ // Cursor is past the object's hit time.
1007
+ if (prevCursor.time > hitTime) {
1008
+ break;
1009
+ }
1010
+ // Cursor is before the object's hit time.
1011
+ if (hitTime > cursor.time) {
1012
+ continue;
1013
+ }
1014
+ let distance;
1015
+ const currentPosition = this.getCursorPosition(cursor);
1016
+ const prevPosition = this.getCursorPosition(prevCursor);
1017
+ switch (cursor.id) {
1018
+ case exports.MovementType.up:
1019
+ distance = prevPosition.getDistance(objectPosition);
1020
+ break;
1021
+ case exports.MovementType.move: {
1022
+ // Interpolate movement.
1023
+ const t = (hitTime - prevCursor.time) /
1024
+ (cursor.time - prevCursor.time);
1025
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1026
+ distance =
1027
+ objectPosition.getDistance(cursorPosition);
1028
+ break;
1029
+ }
1030
+ case exports.MovementType.down:
1031
+ continue;
1032
+ }
1033
+ if (closestDistance > distance) {
1034
+ closestDistance = distance;
1035
+ nearestCursorIndex = i;
1036
+ }
1037
+ }
1038
+ // Reset cursor index pointer on end of group.
1039
+ if (cursorIndices[i] === cursors.length) {
1040
+ cursorIndices[i] = 1;
1041
+ }
1042
+ break;
1043
+ }
1044
+ // The previous object may still be hit with the same cursor group or cursor index.
1045
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
1046
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
1047
+ }
1048
+ return nearestCursorIndex;
1049
+ }
1050
+ /**
1051
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
1052
+ *
1053
+ * @param object The object to obtain the index for.
1054
+ * @param objectData The hit data of the object.
1055
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1056
+ * @param excludedIndices The cursor indices that should not be checked.
1057
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1058
+ */
1059
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
1060
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1061
+ return -1;
1062
+ }
1063
+ // Check for sliderbreaks and treat them as misses.
1064
+ if (object instanceof osuBase.Slider &&
1065
+ (-this.hitWindow.mehWindow > objectData.accuracy ||
1066
+ objectData.accuracy >
1067
+ Math.min(this.hitWindow.mehWindow, object.duration))) {
1068
+ return -1;
1069
+ }
1070
+ const hitTime = object.startTime + objectData.accuracy;
1071
+ let nearestCursorInstanceIndex = -1;
1072
+ let nearestTime = Number.POSITIVE_INFINITY;
1073
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
1074
+ const cursors = this.downCursorInstances[i];
1075
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
1076
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
1077
+ const cursor = cursors[j];
1078
+ if (cursor.time > hitTime) {
1079
+ break;
1080
+ }
1081
+ cursorNearestTime = hitTime - cursor.time;
1082
+ }
1083
+ if (cursorNearestTime < nearestTime) {
1084
+ nearestCursorInstanceIndex = i;
1085
+ nearestTime = cursorNearestTime;
1086
+ }
1087
+ }
1088
+ return nearestCursorInstanceIndex;
1089
+ }
1090
+ /**
1091
+ * Creates nerf factors by scanning through objects.
1092
+ */
1093
+ calculateNerfFactors() {
1094
+ for (const beatmapSection of this.beatmapSections) {
1095
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
1096
+ for (const object of beatmapSection.objects) {
1097
+ if (object.pressingCursorInstanceIndex === -1) {
1098
+ continue;
1099
+ }
1100
+ if (object.aimingCursorInstanceIndex < 3) {
1101
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
1102
+ switch (object.pressingCursorInstanceIndex) {
1103
+ case 0:
1104
+ case 1:
1105
+ case 2:
1106
+ break;
1107
+ default:
1108
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
1109
+ break;
1110
+ }
1111
+ }
1112
+ else {
1113
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
1114
+ switch (object.pressingCursorInstanceIndex) {
1115
+ case 0:
1116
+ case 1:
1117
+ break;
1118
+ default:
1119
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
1120
+ break;
1121
+ }
1122
+ }
1123
+ }
1124
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
1125
+ if (threeFingerCursorCount === 0) {
1126
+ continue;
1127
+ }
1128
+ const sectionObjectCount = beatmapSection.objects.length;
1129
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
1130
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
1131
+ // Finger factor applies more penalty if more fingers were used.
1132
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1133
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
1134
+ // Length factor applies more penalty if there are more 3-fingered object.
1135
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
1136
+ this.nerfFactors.push({
1137
+ strainFactor: strainFactor,
1138
+ fingerFactor: fingerFactor,
1139
+ lengthFactor: lengthFactor,
1140
+ });
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Calculates the final penalty.
1145
+ */
1146
+ calculateFinalPenalty() {
1147
+ return this.nerfFactors.reduce((a, n) => a +
1148
+ 0.015 *
1149
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1150
+ }
1151
+ getCursorPosition(cursor) {
1152
+ if (this.isHardRock) {
1153
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1154
+ }
1155
+ return cursor.position;
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Contains information about which cursor index hits a hitobject.
1161
+ */
1162
+ class IndexedHitObject {
1163
+ /**
1164
+ * @param object The underlying difficulty hitobject.
1165
+ * @param cursorIndex The cursor index that moves towards the hitobject.
1166
+ * @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
1167
+ * @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
1168
+ * @param angle The angle of the movement of the cursor that moves towards the hitobject.
1169
+ */
1170
+ constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
1171
+ /**
1172
+ * If this is a slider, whether the slider was cheesed.
1173
+ */
1174
+ this.sliderCheesed = false;
1175
+ this.object = object;
1176
+ this.cursorIndex = cursorIndex;
1177
+ this.groupIndex = groupIndex;
1178
+ this.occurrenceIndex = occurrenceIndex;
1179
+ this.angle = angle;
1180
+ this.is2Handed = is2Handed;
1181
+ this.endCursorPosition = this.object.object.getStackedEndPosition(osuBase.Modes.droid);
1182
+ }
1183
+ }
1184
+
1185
+ // import { writeFileSync } from "fs";
1186
+ /**
1187
+ * Utility to check whether or not a beatmap is two-handed.
1188
+ */
1189
+ class TwoHandChecker {
1190
+ // private csvString: string;
1044
1191
  /**
1045
1192
  * @param calculator The difficulty calculator to analyze.
1046
1193
  * @param data The data of the replay.
1047
1194
  */
1048
1195
  constructor(calculator, data) {
1196
+ /**
1197
+ * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1198
+ */
1199
+ this.indexedHitObjects = [];
1049
1200
  this.calculator = calculator;
1050
1201
  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));
1202
+ const greatWindow = new osuBase.OsuHitWindow(calculator.attributes.overallDifficulty)
1203
+ .greatWindow * calculator.attributes.clockRate;
1204
+ this.hitWindow = calculator.mods.some((m) => m instanceof osuBase.ModPrecise)
1205
+ ? new osuBase.PreciseDroidHitWindow(osuBase.PreciseDroidHitWindow.greatWindowToOD(greatWindow))
1206
+ : new osuBase.DroidHitWindow(osuBase.DroidHitWindow.greatWindowToOD(greatWindow));
1207
+ this.isHardRock = calculator.mods.some((m) => m instanceof osuBase.ModHardRock);
1058
1208
  // this.csvString = `Mods,${
1059
1209
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
1060
1210
  // }\nCombo,${data.maxCombo}\nAccuracy,"${(
@@ -1236,9 +1386,8 @@ class TwoHandChecker {
1236
1386
  const prevToCurrentMovement = object
1237
1387
  .getStackedPosition(osuBase.Modes.droid)
1238
1388
  .subtract(prevObjectEndPosition);
1239
- const radius = object.getRadius(osuBase.Modes.droid);
1240
1389
  // Don't consider objects that are too close to each other.
1241
- if (prevToCurrentMovement.length <= radius) {
1390
+ if (prevToCurrentMovement.length <= object.radius) {
1242
1391
  return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1243
1392
  }
1244
1393
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
@@ -1276,13 +1425,15 @@ class TwoHandChecker {
1276
1425
  for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
1277
1426
  const cursor = cursors[i];
1278
1427
  const prevCursor = cursors[i - 1];
1279
- if (cursor.position.equals(prevCursor.position)) {
1428
+ const currentPosition = this.getCursorPosition(cursor);
1429
+ const prevPosition = this.getCursorPosition(prevCursor);
1430
+ if (currentPosition.equals(prevPosition)) {
1280
1431
  continue;
1281
1432
  }
1282
1433
  if (cursor.id === exports.MovementType.up) {
1283
1434
  break;
1284
1435
  }
1285
- const currentMovement = cursor.position.subtract(prevCursor.position);
1436
+ const currentMovement = currentPosition.subtract(prevPosition);
1286
1437
  const dot = prevToCurrentCursorMovement.dot(currentMovement);
1287
1438
  const det = prevToCurrentCursorMovement.x * currentMovement.y -
1288
1439
  prevToCurrentCursorMovement.y * currentMovement.x;
@@ -1367,22 +1518,20 @@ class TwoHandChecker {
1367
1518
  cursorTime: object.startTime,
1368
1519
  };
1369
1520
  }
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);
1521
+ let hitWindow = this.hitWindow.mehWindow;
1373
1522
  // For sliders, set the hit window to as lenient as possible.
1374
1523
  if (object instanceof osuBase.Circle) {
1375
1524
  switch (data.result) {
1376
1525
  case exports.HitResult.great:
1377
- hitWindow = this.hitWindow.hitWindowFor300(isPrecise);
1526
+ hitWindow = this.hitWindow.greatWindow;
1378
1527
  break;
1379
1528
  case exports.HitResult.good:
1380
- hitWindow = this.hitWindow.hitWindowFor100(isPrecise);
1529
+ hitWindow = this.hitWindow.okWindow;
1381
1530
  break;
1382
1531
  }
1383
1532
  }
1384
1533
  // TODO: what to do for head sliderbreaks?
1385
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1534
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1386
1535
  let nearestCursorIndex = 0;
1387
1536
  let nearestGroupIndex = 0;
1388
1537
  let nearestCursorGroupIndex = 0;
@@ -1390,12 +1539,12 @@ class TwoHandChecker {
1390
1539
  const minimumActiveTime = object.startTime - hitWindow;
1391
1540
  const maximumActiveTime = object.startTime + hitWindow;
1392
1541
  for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1393
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1542
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1394
1543
  break;
1395
1544
  }
1396
1545
  const cursorData = this.data.cursorMovement[i];
1397
1546
  for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
1398
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1547
+ if (nearestPosition.getDistance(objectPosition) <= object.radius) {
1399
1548
  break;
1400
1549
  }
1401
1550
  const cursorGroup = cursorData.occurrenceGroups[j];
@@ -1407,13 +1556,14 @@ class TwoHandChecker {
1407
1556
  }
1408
1557
  // Validate the down press first.
1409
1558
  const { down } = cursorGroup;
1410
- if (down.position.getDistance(objectPosition) <= radius &&
1559
+ const downPosition = this.getCursorPosition(down);
1560
+ if (downPosition.getDistance(objectPosition) <= object.radius &&
1411
1561
  Math.abs(down.time - object.startTime) <= hitWindow) {
1412
1562
  if (objectIndex > 0) {
1413
1563
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1414
1564
  if (down.time > prevObject.endTime) {
1415
1565
  return {
1416
- position: down.position,
1566
+ position: downPosition,
1417
1567
  cursorIndex: i,
1418
1568
  groupIndex: j,
1419
1569
  occurrenceIndex: 0,
@@ -1423,7 +1573,7 @@ class TwoHandChecker {
1423
1573
  }
1424
1574
  else {
1425
1575
  return {
1426
- position: down.position,
1576
+ position: downPosition,
1427
1577
  cursorIndex: i,
1428
1578
  groupIndex: j,
1429
1579
  occurrenceIndex: 0,
@@ -1443,7 +1593,7 @@ class TwoHandChecker {
1443
1593
  }
1444
1594
  let cursorPosition;
1445
1595
  if (cursor.id === exports.MovementType.up) {
1446
- cursorPosition = prevCursor.position;
1596
+ cursorPosition = this.getCursorPosition(prevCursor);
1447
1597
  const distance = cursorPosition.getDistance(objectPosition);
1448
1598
  if (distance <
1449
1599
  nearestPosition.getDistance(objectPosition)) {
@@ -1468,7 +1618,7 @@ class TwoHandChecker {
1468
1618
  }
1469
1619
  const t = (cursorGroup.down.time - prevCursor.time) /
1470
1620
  (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));
1621
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1472
1622
  const distance = cursorPosition.getDistance(objectPosition);
1473
1623
  if (distance <
1474
1624
  nearestPosition.getDistance(objectPosition)) {
@@ -1478,12 +1628,13 @@ class TwoHandChecker {
1478
1628
  nearestCursorGroupIndex = k;
1479
1629
  nearestCursorTime = cursorGroup.down.time;
1480
1630
  }
1481
- if (distance <= radius) {
1631
+ if (distance <= object.radius) {
1482
1632
  break;
1483
1633
  }
1484
1634
  }
1485
1635
  }
1486
- if (nearestPosition.getDistance(objectPosition) <= radius) {
1636
+ if (nearestPosition.getDistance(objectPosition) <=
1637
+ object.radius) {
1487
1638
  break;
1488
1639
  }
1489
1640
  }
@@ -1531,7 +1682,7 @@ class TwoHandChecker {
1531
1682
  else {
1532
1683
  objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
1533
1684
  }
1534
- let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
1685
+ let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
1535
1686
  let nearestCursorIndex = 0;
1536
1687
  let nearestGroupIndex = 0;
1537
1688
  let nearestCursorGroupIndex = 0;
@@ -1552,18 +1703,18 @@ class TwoHandChecker {
1552
1703
  let cursorPosition;
1553
1704
  switch (cursor.id) {
1554
1705
  case exports.MovementType.down:
1555
- cursorPosition = cursor.position;
1706
+ cursorPosition = this.getCursorPosition(cursor);
1556
1707
  break;
1557
1708
  case exports.MovementType.up: {
1558
1709
  const prevCursor = cursors[k - 1];
1559
- cursorPosition = prevCursor.position;
1710
+ cursorPosition = this.getCursorPosition(prevCursor);
1560
1711
  break;
1561
1712
  }
1562
1713
  case exports.MovementType.move: {
1563
1714
  const prevCursor = cursors[k - 1];
1564
1715
  const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
1565
1716
  (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));
1717
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1567
1718
  break;
1568
1719
  }
1569
1720
  }
@@ -1625,28 +1776,18 @@ class TwoHandChecker {
1625
1776
  }
1626
1777
  return false;
1627
1778
  }
1779
+ getCursorPosition(cursor) {
1780
+ if (this.isHardRock) {
1781
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1782
+ }
1783
+ return cursor.position;
1784
+ }
1628
1785
  }
1629
1786
 
1630
1787
  /**
1631
1788
  * Utility to check whether relevant sliders in a beatmap are cheesed.
1632
1789
  */
1633
1790
  class SliderCheeseChecker {
1634
- /**
1635
- * The beatmap that is being analyzed.
1636
- */
1637
- beatmap;
1638
- /**
1639
- * The data of the replay.
1640
- */
1641
- data;
1642
- /**
1643
- * The difficulty attributes of the beatmap.
1644
- */
1645
- difficultyAttributes;
1646
- /**
1647
- * The 50 osu!droid hit window of the analyzed beatmap.
1648
- */
1649
- hitWindow50;
1650
1791
  /**
1651
1792
  * @param beatmap The beatmap to analyze.
1652
1793
  * @param data The data of the replay.
@@ -1656,12 +1797,10 @@ class SliderCheeseChecker {
1656
1797
  this.beatmap = beatmap;
1657
1798
  this.data = data;
1658
1799
  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));
1800
+ this.hitWindow50 = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise)
1801
+ ? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
1802
+ : new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
1803
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
1665
1804
  }
1666
1805
  /**
1667
1806
  * Checks if relevant sliders in the given beatmap was cheesed.
@@ -1687,13 +1826,8 @@ class SliderCheeseChecker {
1687
1826
  const { objects } = this.beatmap.hitObjects;
1688
1827
  const cheesedDifficultyRatings = [];
1689
1828
  // 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;
1829
+ const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1830
+ const acceptableRadius = objects[0].radius * 2;
1697
1831
  // Sort difficult sliders by index so that cursor loop indices work properly.
1698
1832
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
1699
1833
  .slice()
@@ -1701,18 +1835,15 @@ class SliderCheeseChecker {
1701
1835
  if (difficultSlider.index >= this.data.hitObjectData.length) {
1702
1836
  continue;
1703
1837
  }
1838
+ const object = objects[difficultSlider.index];
1704
1839
  const objectData = this.data.hitObjectData[difficultSlider.index];
1705
1840
  // If a miss or slider break occurs, we disregard the check for that slider.
1706
1841
  if (objectData.result === exports.HitResult.miss ||
1707
- objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1842
+ -this.hitWindow50 > objectData.accuracy ||
1843
+ objectData.accuracy >
1844
+ Math.min(this.hitWindow50, object.duration)) {
1708
1845
  continue;
1709
1846
  }
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
1847
  const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1717
1848
  // These time boundaries should consider the delta time between the previous and next
1718
1849
  // object as well as their hit accuracy. However, they are somewhat complicated to
@@ -1735,7 +1866,8 @@ class SliderCheeseChecker {
1735
1866
  break;
1736
1867
  }
1737
1868
  if (group.startTime >= minTimeLimit) {
1738
- const distance = group.down.position.getDistance(objectStartPosition);
1869
+ const position = this.getCursorPosition(group.down);
1870
+ const distance = position.getDistance(objectStartPosition);
1739
1871
  if (closestDistance > distance) {
1740
1872
  closestDistance = distance;
1741
1873
  closestIndex = j;
@@ -1744,51 +1876,45 @@ class SliderCheeseChecker {
1744
1876
  break;
1745
1877
  }
1746
1878
  }
1879
+ // Normally, we check if there are cursor presses within the group's active time.
1880
+ // However, some funky workarounds are used throughout the game for replays, so
1881
+ // for the time being we only check for cursor distances across the group.
1747
1882
  const { allOccurrences } = group;
1748
- // Check if there are cursor presses within the group's active time.
1749
- for (let k = 0; k < this.data.cursorMovement.length; ++k) {
1750
- // Skip the current cursor index.
1751
- if (k === i) {
1752
- continue;
1753
- }
1754
- const cursorGroups = this.data.cursorMovement[k].occurrenceGroups;
1755
- for (const cursorGroup of cursorGroups) {
1756
- if (cursorGroup.startTime < minTimeLimit) {
1757
- continue;
1758
- }
1759
- if (cursorGroup.startTime > maxTimeLimit) {
1883
+ for (let k = 1; k < allOccurrences.length; ++k) {
1884
+ const cursor = allOccurrences[k];
1885
+ const prevCursor = allOccurrences[k - 1];
1886
+ let distance = Number.POSITIVE_INFINITY;
1887
+ const currentPosition = this.getCursorPosition(cursor);
1888
+ const prevPosition = this.getCursorPosition(prevCursor);
1889
+ switch (cursor.id) {
1890
+ case exports.MovementType.up:
1891
+ distance =
1892
+ prevPosition.getDistance(objectStartPosition);
1760
1893
  break;
1761
- }
1762
- const cursorIndex = allOccurrences.findIndex((v) => v.time >= cursorGroup.startTime &&
1763
- v.time <= maxTimeLimit);
1764
- if (cursorIndex === -1) {
1765
- continue;
1766
- }
1767
- const occurrence = allOccurrences[cursorIndex];
1768
- const prevOccurrence = allOccurrences[cursorIndex - 1];
1769
- let distance = Number.POSITIVE_INFINITY;
1770
- // We will not consider presses here as it has already been processed above.
1771
- switch (occurrence.id) {
1772
- case exports.MovementType.move: {
1773
- const t = (cursorGroup.startTime -
1774
- prevOccurrence.time) /
1775
- (occurrence.time - prevOccurrence.time);
1776
- 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));
1894
+ case exports.MovementType.move:
1895
+ for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
1896
+ Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
1897
+ const t = (mSecPassed - prevCursor.time) /
1898
+ (cursor.time - prevCursor.time);
1899
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1777
1900
  distance =
1778
1901
  cursorPosition.getDistance(objectStartPosition);
1779
- break;
1902
+ if (closestDistance > distance) {
1903
+ closestDistance = distance;
1904
+ closestIndex = j;
1905
+ }
1906
+ if (closestDistance <=
1907
+ acceptableRadius / 2) {
1908
+ break;
1909
+ }
1780
1910
  }
1781
- case exports.MovementType.up:
1782
- distance =
1783
- prevOccurrence.position.getDistance(objectStartPosition);
1784
- }
1785
- if (closestDistance > distance) {
1786
- closestDistance = distance;
1787
- closestIndex = j;
1788
- }
1789
- if (closestDistance <= acceptableRadius / 2) {
1790
- break;
1791
- }
1911
+ }
1912
+ if (closestDistance > distance) {
1913
+ closestDistance = distance;
1914
+ closestIndex = j;
1915
+ }
1916
+ if (closestDistance <= acceptableRadius / 2) {
1917
+ break;
1792
1918
  }
1793
1919
  }
1794
1920
  }
@@ -1819,14 +1945,6 @@ class SliderCheeseChecker {
1819
1945
  continue;
1820
1946
  }
1821
1947
  const nestedObject = object.nestedHitObjects[i];
1822
- nestedObject.droidScale = scale;
1823
- // Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
1824
- // In that case, its time is assumed to be 36ms behind the slider's end time. However, that
1825
- // is not the case for osu!droid.
1826
- if (nestedObject instanceof osuBase.SliderTail) {
1827
- nestedObject.startTime = object.endTime;
1828
- nestedObject.endTime = object.endTime;
1829
- }
1830
1948
  const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1831
1949
  while (occurrenceLoopIndex < allOccurrences.length &&
1832
1950
  allOccurrences[occurrenceLoopIndex].time <
@@ -1836,21 +1954,24 @@ class SliderCheeseChecker {
1836
1954
  if (occurrenceLoopIndex === allOccurrences.length) {
1837
1955
  continue;
1838
1956
  }
1839
- const occurrence = allOccurrences[occurrenceLoopIndex];
1840
- const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
1841
- switch (occurrence.id) {
1957
+ const cursor = allOccurrences[occurrenceLoopIndex];
1958
+ const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
1959
+ const currentPosition = this.getCursorPosition(cursor);
1960
+ const prevPosition = this.getCursorPosition(prevCursor);
1961
+ switch (cursor.id) {
1842
1962
  case exports.MovementType.move: {
1843
1963
  // Interpolate cursor position during nested object time.
1844
- const t = (nestedObject.startTime - prevOccurrence.time) /
1845
- (occurrence.time - prevOccurrence.time);
1846
- 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));
1964
+ const t = (nestedObject.startTime - prevCursor.time) /
1965
+ (cursor.time - prevCursor.time);
1966
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1847
1967
  const distance = cursorPosition.getDistance(nestedPosition);
1848
1968
  isCheesed = distance > acceptableRadius;
1849
1969
  break;
1850
1970
  }
1851
1971
  case exports.MovementType.up:
1852
1972
  isCheesed =
1853
- prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
1973
+ prevPosition.getDistance(nestedPosition) >
1974
+ acceptableRadius;
1854
1975
  }
1855
1976
  }
1856
1977
  if (isCheesed) {
@@ -1876,87 +1997,178 @@ class SliderCheeseChecker {
1876
1997
  this.difficultyAttributes.visualSliderFactor, 2)),
1877
1998
  };
1878
1999
  }
2000
+ getCursorPosition(cursor) {
2001
+ if (this.isHardRock) {
2002
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
2003
+ }
2004
+ return cursor.position;
2005
+ }
1879
2006
  }
1880
2007
 
1881
2008
  /**
1882
- * A replay analyzer that analyzes a replay from osu!droid.
2009
+ * Represents a replay data for replay version 3.
1883
2010
  *
1884
- * 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}.
2011
+ * Stores generic information about an osu!droid replay.
1885
2012
  *
1886
- * Once analyzed, the result can be accessed via the `data` property.
2013
+ * This is used when analyzing replays using replay analyzer.
1887
2014
  */
1888
- class ReplayAnalyzer {
2015
+ class ReplayV3Data extends ReplayData {
1889
2016
  /**
1890
- * The score ID of the replay.
2017
+ * The complete mod string of this replay (mods, speed multiplier, and forced difficulty statistics combined).
1891
2018
  */
1892
- scoreID;
1893
- /**
1894
- * The original odr file of the replay.
1895
- */
1896
- originalODR = null;
1897
- /**
1898
- * The fixed odr file of the replay.
1899
- */
1900
- fixedODR = null;
1901
- /**
1902
- * Whether or not the play is considered using >=3 finger abuse.
1903
- */
1904
- is3Finger;
2019
+ get completeModString() {
2020
+ return this.modString;
2021
+ }
1905
2022
  /**
1906
- * Whether or not the play is considered 2-handed.
2023
+ * The mod string of this replay (mods only).
1907
2024
  */
1908
- is2Hand;
1909
- /**
1910
- * The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
1911
- */
1912
- beatmap;
1913
- /**
1914
- * The difficulty attributes of the beatmap.
1915
- */
1916
- difficultyAttributes;
1917
- /**
1918
- * The results of the analyzer. `null` when initialized.
1919
- */
1920
- data = null;
1921
- /**
1922
- * Penalty value used to penalize dpp for 2-hand.
1923
- */
1924
- aimPenalty = 1;
1925
- /**
1926
- * Penalty value used to penalize dpp for 3 finger abuse.
1927
- */
1928
- tapPenalty = 1;
1929
- /**
1930
- * Penalty values used to penalize dpp for slider cheesing.
1931
- */
1932
- sliderCheesePenalty = {
1933
- aimPenalty: 1,
1934
- flashlightPenalty: 1,
1935
- visualPenalty: 1,
1936
- };
1937
- /**
1938
- * Whether this replay has been checked against 3 finger usage.
1939
- */
1940
- hasBeenCheckedFor3Finger = false;
1941
- /**
1942
- * Whether this replay has been checked against 2 hand usage.
1943
- */
1944
- hasBeenCheckedFor2Hand = false;
1945
- /**
1946
- * Whether this repla has been checked against slider cheesing.
1947
- */
1948
- hasBeenCheckedForSliderCheesing = false;
1949
- /**
1950
- * The amount of two-handed objects.
1951
- */
1952
- twoHandedNoteCount = 0;
1953
- // Sizes of primitive data types in Java (in bytes)
1954
- BYTE_LENGTH = 1;
1955
- SHORT_LENGTH = 2;
1956
- INT_LENGTH = 4;
1957
- FLOAT_LENGTH = 4;
1958
- LONG_LENGTH = 8;
2025
+ get modString() {
2026
+ return this.convertedMods.length > 0
2027
+ ? this.convertedMods.map((v) => v.acronym).join("")
2028
+ : "No Mod";
2029
+ }
1959
2030
  constructor(values) {
2031
+ super(values);
2032
+ this.time = values.time;
2033
+ this.score = values.score;
2034
+ this.maxCombo = values.maxCombo;
2035
+ this.isFullCombo = values.isFullCombo;
2036
+ this.playerName = values.playerName;
2037
+ this.rawMods = values.rawMods;
2038
+ this.convertedMods = values.convertedMods;
2039
+ }
2040
+ }
2041
+
2042
+ /**
2043
+ * Represents a replay data for replay version 4.
2044
+ *
2045
+ * Stores generic information about an osu!droid replay.
2046
+ *
2047
+ * This is used when analyzing replays using replay analyzer.
2048
+ */
2049
+ class ReplayV4Data extends ReplayV3Data {
2050
+ get completeModString() {
2051
+ let finalString = this.modString;
2052
+ const customStats = [];
2053
+ if (this.speedMultiplier !== 1) {
2054
+ customStats.push(`${this.speedMultiplier}x`);
2055
+ }
2056
+ if (this.forceAR !== undefined) {
2057
+ customStats.push(`AR${this.forceAR}`);
2058
+ }
2059
+ if (this.flashlightFollowDelay !== undefined) {
2060
+ customStats.push(`FLD${this.flashlightFollowDelay}`);
2061
+ }
2062
+ if (customStats.length > 0) {
2063
+ finalString += ` (${customStats.join(", ")})`;
2064
+ }
2065
+ return finalString;
2066
+ }
2067
+ constructor(values) {
2068
+ var _a, _b;
2069
+ super(values);
2070
+ this.forceAR = values.forceAR;
2071
+ this.speedMultiplier = (_a = values.speedMultiplier) !== null && _a !== void 0 ? _a : 1;
2072
+ this.flashlightFollowDelay = (_b = values.flashlightFollowDelay) !== null && _b !== void 0 ? _b : 0.12;
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * Represents a replay data for replay version 5.
2078
+ *
2079
+ * Stores generic information about an osu!droid replay.
2080
+ *
2081
+ * This is used when analyzing replays using replay analyzer.
2082
+ */
2083
+ class ReplayV5Data extends ReplayV4Data {
2084
+ get completeModString() {
2085
+ let finalString = this.modString;
2086
+ const customStats = [];
2087
+ if (this.speedMultiplier !== 1) {
2088
+ customStats.push(`${this.speedMultiplier}x`);
2089
+ }
2090
+ if (this.forceAR !== undefined) {
2091
+ customStats.push(`AR${this.forceAR}`);
2092
+ }
2093
+ if (this.forceOD !== undefined) {
2094
+ customStats.push(`OD${this.forceOD}`);
2095
+ }
2096
+ if (this.forceCS !== undefined) {
2097
+ customStats.push(`CS${this.forceCS}`);
2098
+ }
2099
+ if (this.forceHP !== undefined) {
2100
+ customStats.push(`HP${this.forceHP}`);
2101
+ }
2102
+ if (this.flashlightFollowDelay !== undefined) {
2103
+ customStats.push(`FLD${this.flashlightFollowDelay}`);
2104
+ }
2105
+ if (customStats.length > 0) {
2106
+ finalString += ` (${customStats.join(", ")})`;
2107
+ }
2108
+ return finalString;
2109
+ }
2110
+ constructor(values) {
2111
+ super(values);
2112
+ this.forceCS = values.forceCS;
2113
+ this.forceOD = values.forceOD;
2114
+ this.forceHP = values.forceHP;
2115
+ }
2116
+ }
2117
+
2118
+ /**
2119
+ * A replay analyzer that analyzes a replay from osu!droid.
2120
+ *
2121
+ * 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}.
2122
+ *
2123
+ * Once analyzed, the result can be accessed via the `data` property.
2124
+ */
2125
+ class ReplayAnalyzer {
2126
+ constructor(values) {
2127
+ /**
2128
+ * The original odr file of the replay.
2129
+ */
2130
+ this.originalODR = null;
2131
+ /**
2132
+ * The fixed odr file of the replay.
2133
+ */
2134
+ this.fixedODR = null;
2135
+ /**
2136
+ * The results of the analyzer. `null` when initialized.
2137
+ */
2138
+ this.data = null;
2139
+ /**
2140
+ * Penalty value used to penalize dpp for 2-hand.
2141
+ */
2142
+ this.aimPenalty = 1;
2143
+ /**
2144
+ * Penalty value used to penalize dpp for 3 finger abuse.
2145
+ */
2146
+ this.tapPenalty = 1;
2147
+ /**
2148
+ * Penalty values used to penalize dpp for slider cheesing.
2149
+ */
2150
+ this.sliderCheesePenalty = {
2151
+ aimPenalty: 1,
2152
+ flashlightPenalty: 1,
2153
+ visualPenalty: 1,
2154
+ };
2155
+ /**
2156
+ * Whether this replay has been checked against 3 finger usage.
2157
+ */
2158
+ this.hasBeenCheckedFor3Finger = false;
2159
+ /**
2160
+ * Whether this replay has been checked against 2 hand usage.
2161
+ */
2162
+ this.hasBeenCheckedFor2Hand = false;
2163
+ /**
2164
+ * Whether this replay has been checked against slider cheesing.
2165
+ */
2166
+ this.hasBeenCheckedForSliderCheesing = false;
2167
+ /**
2168
+ * The amount of two-handed objects.
2169
+ */
2170
+ this.twoHandedNoteCount = 0;
2171
+ this.bufferOffset = 0;
1960
2172
  this.scoreID = values.scoreID;
1961
2173
  this.beatmap = values.map;
1962
2174
  this.difficultyAttributes = values.difficultyAttributes;
@@ -1967,35 +2179,150 @@ class ReplayAnalyzer {
1967
2179
  /**
1968
2180
  * Analyzes a replay.
1969
2181
  */
1970
- async analyze() {
1971
- if (!this.originalODR && !this.fixedODR) {
1972
- this.originalODR = await this.downloadReplay();
1973
- }
1974
- if (!this.originalODR) {
2182
+ analyze() {
2183
+ return __awaiter(this, void 0, void 0, function* () {
2184
+ if (!this.originalODR && !this.fixedODR) {
2185
+ this.originalODR = yield this.downloadReplay();
2186
+ }
2187
+ if (!this.originalODR) {
2188
+ return this;
2189
+ }
2190
+ if (!this.fixedODR) {
2191
+ this.fixedODR = yield this.decompress().catch(() => null);
2192
+ }
2193
+ if (!this.fixedODR) {
2194
+ return this;
2195
+ }
2196
+ this.parseReplay();
1975
2197
  return this;
2198
+ });
2199
+ }
2200
+ /**
2201
+ * Gets hit error information of the replay.
2202
+ *
2203
+ * `analyze()` must be called before calling this.
2204
+ */
2205
+ calculateHitError() {
2206
+ var _a, _b;
2207
+ if (!this.data || !this.beatmap) {
2208
+ return null;
1976
2209
  }
1977
- if (!this.fixedODR) {
1978
- this.fixedODR = await this.decompress().catch(() => null);
2210
+ const hitObjectData = this.data.hitObjectData;
2211
+ let positiveCount = 0;
2212
+ let negativeCount = 0;
2213
+ let positiveTotal = 0;
2214
+ let negativeTotal = 0;
2215
+ const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2216
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2217
+ ? this.beatmap.beatmap
2218
+ : this.beatmap;
2219
+ const { objects } = beatmap.hitObjects;
2220
+ const mods = this.data.isReplayV3()
2221
+ ? this.data.convertedMods
2222
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.filter((m) => m.isApplicableToDroid())) !== null && _b !== void 0 ? _b : [];
2223
+ const adjustedDifficulty = new osuBase.BeatmapDifficulty(beatmap.difficulty);
2224
+ osuBase.ModUtil.applyModsToBeatmapDifficulty(adjustedDifficulty, osuBase.Modes.droid, mods, this.data.isReplayV4() ? this.data.speedMultiplier : 1);
2225
+ const mehWindow = mods.some((m) => m instanceof osuBase.ModPrecise)
2226
+ ? new osuBase.PreciseDroidHitWindow(adjustedDifficulty.od).mehWindow
2227
+ : new osuBase.DroidHitWindow(adjustedDifficulty.od).mehWindow;
2228
+ const accuracies = [];
2229
+ for (let i = 0; i < hitObjectData.length; ++i) {
2230
+ const v = hitObjectData[i];
2231
+ const o = objects[i];
2232
+ if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2233
+ accuracies.push(0);
2234
+ continue;
2235
+ }
2236
+ const { accuracy } = v;
2237
+ if (o instanceof osuBase.Slider &&
2238
+ // Do not include slider breaks.
2239
+ (-mehWindow > accuracy ||
2240
+ accuracy > Math.min(mehWindow, o.duration))) {
2241
+ accuracies.push(0);
2242
+ continue;
2243
+ }
2244
+ accuracies.push(accuracy);
2245
+ if (accuracy >= 0) {
2246
+ positiveTotal += accuracy;
2247
+ ++positiveCount;
2248
+ }
2249
+ else {
2250
+ negativeTotal += accuracy;
2251
+ ++negativeCount;
2252
+ }
1979
2253
  }
1980
- if (!this.fixedODR) {
1981
- return this;
2254
+ return {
2255
+ positiveAvg: positiveTotal / positiveCount || 0,
2256
+ negativeAvg: negativeTotal / negativeCount || 0,
2257
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2258
+ };
2259
+ }
2260
+ /**
2261
+ * Checks if a play is using 3 fingers.
2262
+ *
2263
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2264
+ */
2265
+ checkFor3Finger() {
2266
+ var _a;
2267
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2268
+ return;
1982
2269
  }
1983
- this.parseReplay();
1984
- return this;
2270
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2271
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2272
+ ? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
2273
+ : new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2274
+ const result = threeFingerChecker.check();
2275
+ this.is3Finger = result.is3Finger;
2276
+ this.tapPenalty = result.penalty;
2277
+ this.hasBeenCheckedFor3Finger = true;
1985
2278
  }
1986
2279
  /**
1987
- * Downloads the given score ID's replay.
2280
+ * Checks if a play is using 2 hands.
2281
+ *
2282
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
1988
2283
  */
1989
- async downloadReplay() {
1990
- const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1991
- .setRequireAPIkey(false)
1992
- .setEndpoint("upload")
1993
- .addParameter("", `${this.scoreID}.odr`);
1994
- const result = await apiRequestBuilder.sendRequest();
1995
- if (result.statusCode !== 200) {
1996
- return null;
2284
+ checkFor2Hand() {
2285
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2286
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2287
+ !this.data) {
2288
+ return;
2289
+ }
2290
+ const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2291
+ const result = twoHandChecker.check();
2292
+ this.is2Hand = result.is2Hand;
2293
+ this.twoHandedNoteCount = result.twoHandedNoteCount;
2294
+ this.hasBeenCheckedFor2Hand = true;
2295
+ }
2296
+ /**
2297
+ * Checks if a play has cheesed sliders.
2298
+ *
2299
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2300
+ */
2301
+ checkForSliderCheesing() {
2302
+ var _a;
2303
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2304
+ return;
1997
2305
  }
1998
- return result.data;
2306
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2307
+ const sliderCheeseChecker = new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2308
+ this.sliderCheesePenalty = sliderCheeseChecker.check();
2309
+ this.hasBeenCheckedForSliderCheesing = true;
2310
+ }
2311
+ /**
2312
+ * Downloads the given score ID's replay.
2313
+ */
2314
+ downloadReplay() {
2315
+ return __awaiter(this, void 0, void 0, function* () {
2316
+ const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
2317
+ .setRequireAPIkey(false)
2318
+ .setEndpoint("upload")
2319
+ .addParameter("", `${this.scoreID}.odr`);
2320
+ const result = yield apiRequestBuilder.sendRequest();
2321
+ if (result.statusCode !== 200) {
2322
+ return null;
2323
+ }
2324
+ return result.data;
2325
+ });
1999
2326
  }
2000
2327
  /**
2001
2328
  * Decompresses a replay.
@@ -2009,15 +2336,15 @@ class ReplayAnalyzer {
2009
2336
  stream.push(null);
2010
2337
  stream
2011
2338
  .pipe(unzipper.Parse())
2012
- .on("entry", async (entry) => {
2339
+ .on("entry", (entry) => __awaiter(this, void 0, void 0, function* () {
2013
2340
  const fileName = entry.path;
2014
2341
  if (fileName === "data") {
2015
- return resolve(await entry.buffer());
2342
+ return resolve(yield entry.buffer());
2016
2343
  }
2017
2344
  else {
2018
2345
  entry.autodrain();
2019
2346
  }
2020
- })
2347
+ }))
2021
2348
  .on("error", (e) => {
2022
2349
  setTimeout(() => reject(e), 2000);
2023
2350
  });
@@ -2027,6 +2354,9 @@ class ReplayAnalyzer {
2027
2354
  * Parses a replay after being downloaded and converted to a buffer.
2028
2355
  */
2029
2356
  parseReplay() {
2357
+ if (!this.fixedODR) {
2358
+ return;
2359
+ }
2030
2360
  // javaDeserialization can only somewhat parse some string field
2031
2361
  // the rest will be a buffer that we need to manually parse
2032
2362
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2034,7 +2364,7 @@ class ReplayAnalyzer {
2034
2364
  try {
2035
2365
  rawObject = javaDeserialization__namespace.parse(this.fixedODR);
2036
2366
  }
2037
- catch {
2367
+ catch (_a) {
2038
2368
  return;
2039
2369
  }
2040
2370
  const resultObject = {
@@ -2044,70 +2374,62 @@ class ReplayAnalyzer {
2044
2374
  hash: rawObject[3],
2045
2375
  cursorMovement: [],
2046
2376
  hitObjectData: [],
2377
+ accuracy: new osuBase.Accuracy({ n300: 0 }),
2378
+ rank: "D",
2379
+ convertedMods: [],
2380
+ hit100k: 0,
2381
+ hit300k: 0,
2382
+ isFullCombo: false,
2383
+ maxCombo: 0,
2384
+ playerName: "",
2385
+ rawMods: [],
2386
+ score: 0,
2387
+ time: new Date(0),
2047
2388
  };
2048
2389
  if (resultObject.replayVersion >= 3) {
2049
- resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
2390
+ resultObject.time.setTime(Number(rawObject[4].readBigUInt64BE(0)));
2050
2391
  resultObject.hit300k = rawObject[4].readInt32BE(8);
2392
+ resultObject.accuracy.n300 = rawObject[4].readInt32BE(12);
2051
2393
  resultObject.hit100k = rawObject[4].readInt32BE(16);
2394
+ resultObject.accuracy.n100 = rawObject[4].readInt32BE(20);
2395
+ resultObject.accuracy.n50 = rawObject[4].readInt32BE(24);
2396
+ resultObject.accuracy.nmiss = rawObject[4].readInt32BE(28);
2052
2397
  resultObject.score = rawObject[4].readInt32BE(32);
2053
2398
  resultObject.maxCombo = rawObject[4].readInt32BE(36);
2054
- resultObject.accuracy = new osuBase.Accuracy({
2055
- n300: rawObject[4].readInt32BE(12),
2056
- n100: rawObject[4].readInt32BE(20),
2057
- n50: rawObject[4].readInt32BE(24),
2058
- nmiss: rawObject[4].readInt32BE(28),
2059
- });
2060
- resultObject.isFullCombo = !!rawObject[4][44];
2399
+ resultObject.isFullCombo = resultObject.accuracy.value() === 1;
2061
2400
  resultObject.playerName = rawObject[5];
2062
- resultObject.rawMods = rawObject[6].elements;
2063
- resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
2064
- // Determine rank
2065
- const totalHits = resultObject.accuracy.n300 +
2066
- resultObject.accuracy.n100 +
2067
- resultObject.accuracy.n50 +
2068
- resultObject.accuracy.nmiss;
2069
- const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
2070
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2071
- switch (true) {
2072
- case resultObject.accuracy.value() === 1:
2073
- if (isHidden) {
2074
- resultObject.rank = "XH";
2075
- }
2076
- else {
2077
- resultObject.rank = "X";
2078
- }
2079
- break;
2080
- case hit300Ratio > 0.9 &&
2081
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2082
- !resultObject.accuracy.nmiss:
2083
- if (isHidden) {
2084
- resultObject.rank = "SH";
2085
- }
2086
- else {
2087
- resultObject.rank = "S";
2088
- }
2089
- break;
2090
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2091
- hit300Ratio > 0.9:
2092
- resultObject.rank = "A";
2093
- break;
2094
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2095
- hit300Ratio > 0.8:
2096
- resultObject.rank = "B";
2097
- break;
2098
- case hit300Ratio > 0.6:
2099
- resultObject.rank = "C";
2100
- break;
2101
- default:
2102
- resultObject.rank = "D";
2103
- }
2401
+ resultObject.rawMods = Object.values(rawObject[6].elements);
2402
+ resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(resultObject.rawMods));
2403
+ resultObject.rank = this.calculateRank(resultObject);
2104
2404
  }
2105
2405
  if (resultObject.replayVersion >= 4) {
2106
- const s = rawObject[7].split("|");
2107
- resultObject.speedModification =
2108
- parseFloat(s[0].replace("x", "")) || 1;
2109
- if (s.length > 1) {
2110
- resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
2406
+ const str = rawObject[7].split("|");
2407
+ for (const s of str) {
2408
+ switch (true) {
2409
+ // Forced stats
2410
+ case s.startsWith("CS"):
2411
+ resultObject.forceCS = parseFloat(s.replace("CS", ""));
2412
+ break;
2413
+ case s.startsWith("AR"):
2414
+ resultObject.forceAR = parseFloat(s.replace("AR", ""));
2415
+ break;
2416
+ case s.startsWith("OD"):
2417
+ resultObject.forceOD = parseFloat(s.replace("OD", ""));
2418
+ break;
2419
+ case s.startsWith("HP"):
2420
+ resultObject.forceHP = parseFloat(s.replace("HP", ""));
2421
+ break;
2422
+ // FL follow delay
2423
+ case s.startsWith("FLD"):
2424
+ resultObject.flashlightFollowDelay =
2425
+ parseFloat(s.replace("FLD", "")) || 0.12;
2426
+ break;
2427
+ // Speed multiplier
2428
+ case s.startsWith("x"):
2429
+ resultObject.speedMultiplier =
2430
+ parseFloat(s.replace("x", "")) || 1;
2431
+ break;
2432
+ }
2111
2433
  }
2112
2434
  }
2113
2435
  let bufferIndex;
@@ -2130,39 +2452,88 @@ class ReplayAnalyzer {
2130
2452
  }
2131
2453
  // Merge all cursor movement and hit object data section into one for better control when parsing
2132
2454
  const replayDataBuffer = Buffer.concat(replayDataBufferArray);
2133
- let bufferCounter = 0;
2134
- const size = replayDataBuffer.readInt32BE(bufferCounter);
2135
- bufferCounter += this.INT_LENGTH;
2136
- // Parse movement data
2137
- for (let x = 0; x < size; x++) {
2138
- const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
2139
- bufferCounter += this.INT_LENGTH;
2455
+ this.bufferOffset = 0;
2456
+ this.parseMovementData(resultObject, replayDataBuffer);
2457
+ this.parseHitObjectData(resultObject, replayDataBuffer);
2458
+ this.parseOldReplayInformation(resultObject);
2459
+ switch (resultObject.replayVersion) {
2460
+ case 3:
2461
+ this.data = new ReplayV3Data(resultObject);
2462
+ break;
2463
+ case 4:
2464
+ this.data = new ReplayV4Data(resultObject);
2465
+ break;
2466
+ case 5:
2467
+ case 6:
2468
+ this.data = new ReplayV5Data(resultObject);
2469
+ break;
2470
+ default:
2471
+ this.data = new ReplayData(resultObject);
2472
+ }
2473
+ }
2474
+ /**
2475
+ * Converts replay mods to droid mod string.
2476
+ */
2477
+ convertDroidMods(replayMods) {
2478
+ const replayModsConstants = {
2479
+ MOD_AUTO: new osuBase.ModAuto().droidString,
2480
+ MOD_AUTOPILOT: new osuBase.ModAutopilot().droidString,
2481
+ MOD_NOFAIL: new osuBase.ModNoFail().droidString,
2482
+ MOD_EASY: new osuBase.ModEasy().droidString,
2483
+ MOD_HIDDEN: new osuBase.ModHidden().droidString,
2484
+ MOD_HARDROCK: new osuBase.ModHardRock().droidString,
2485
+ MOD_DOUBLETIME: new osuBase.ModDoubleTime().droidString,
2486
+ MOD_HALFTIME: new osuBase.ModHalfTime().droidString,
2487
+ MOD_NIGHTCORE: new osuBase.ModNightCore().droidString,
2488
+ MOD_PRECISE: new osuBase.ModPrecise().droidString,
2489
+ MOD_SMALLCIRCLE: new osuBase.ModSmallCircle().droidString,
2490
+ MOD_SPEEDUP: new osuBase.ModSpeedUp().droidString,
2491
+ MOD_REALLYEASY: new osuBase.ModReallyEasy().droidString,
2492
+ MOD_RELAX: new osuBase.ModRelax().droidString,
2493
+ MOD_PERFECT: new osuBase.ModPerfect().droidString,
2494
+ MOD_SUDDENDEATH: new osuBase.ModSuddenDeath().droidString,
2495
+ MOD_SCOREV2: new osuBase.ModScoreV2().droidString,
2496
+ MOD_FLASHLIGHT: new osuBase.ModFlashlight().droidString,
2497
+ };
2498
+ let modString = "";
2499
+ for (const mod of replayMods) {
2500
+ for (const property in replayModsConstants) {
2501
+ if (!mod.includes(property)) {
2502
+ continue;
2503
+ }
2504
+ modString +=
2505
+ replayModsConstants[property];
2506
+ break;
2507
+ }
2508
+ }
2509
+ return modString;
2510
+ }
2511
+ parseMovementData(resultObject, replayDataBuffer) {
2512
+ resultObject.cursorMovement.length = 0;
2513
+ const size = this.readInt(replayDataBuffer);
2514
+ for (let i = 0; i < size; i++) {
2515
+ const moveSize = this.readInt(replayDataBuffer);
2140
2516
  const time = [];
2141
2517
  const x = [];
2142
2518
  const y = [];
2143
2519
  const id = [];
2144
- for (let i = 0; i < moveSize; i++) {
2145
- time[i] = replayDataBuffer.readInt32BE(bufferCounter);
2146
- bufferCounter += this.INT_LENGTH;
2147
- id[i] = time[i] & 3;
2148
- time[i] >>= 2;
2149
- if (id[i] !== exports.MovementType.up) {
2520
+ for (let j = 0; j < moveSize; j++) {
2521
+ time[j] = this.readInt(replayDataBuffer);
2522
+ id[j] = time[j] & 3;
2523
+ time[j] >>= 2;
2524
+ if (id[j] !== exports.MovementType.up) {
2150
2525
  if (resultObject.replayVersion >= 5) {
2151
- x[i] = replayDataBuffer.readFloatBE(bufferCounter);
2152
- bufferCounter += this.FLOAT_LENGTH;
2153
- y[i] = replayDataBuffer.readFloatBE(bufferCounter);
2154
- bufferCounter += this.FLOAT_LENGTH;
2526
+ x[j] = this.readFloat(replayDataBuffer);
2527
+ y[j] = this.readFloat(replayDataBuffer);
2155
2528
  }
2156
2529
  else {
2157
- x[i] = replayDataBuffer.readInt16BE(bufferCounter);
2158
- bufferCounter += this.SHORT_LENGTH;
2159
- y[i] = replayDataBuffer.readInt16BE(bufferCounter);
2160
- bufferCounter += this.SHORT_LENGTH;
2530
+ x[j] = this.readShort(replayDataBuffer);
2531
+ y[j] = this.readShort(replayDataBuffer);
2161
2532
  }
2162
2533
  }
2163
2534
  else {
2164
- x[i] = -1;
2165
- y[i] = -1;
2535
+ x[j] = -1;
2536
+ y[j] = -1;
2166
2537
  }
2167
2538
  }
2168
2539
  resultObject.cursorMovement.push(new CursorData({
@@ -2173,25 +2544,23 @@ class ReplayAnalyzer {
2173
2544
  id: id,
2174
2545
  }));
2175
2546
  }
2176
- const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
2177
- bufferCounter += this.INT_LENGTH;
2547
+ }
2548
+ parseHitObjectData(resultObject, replayDataBuffer) {
2549
+ resultObject.hitObjectData.length = 0;
2550
+ const replayObjectLength = this.readInt(replayDataBuffer);
2178
2551
  // Parse result data
2179
2552
  for (let i = 0; i < replayObjectLength; i++) {
2180
2553
  const replayObjectData = {
2181
2554
  accuracy: 0,
2182
2555
  tickset: [],
2183
- result: 0,
2556
+ result: exports.HitResult.miss,
2184
2557
  };
2185
- replayObjectData.accuracy =
2186
- replayDataBuffer.readInt16BE(bufferCounter);
2187
- bufferCounter += this.SHORT_LENGTH;
2188
- const len = replayDataBuffer.readInt8(bufferCounter);
2189
- bufferCounter += this.BYTE_LENGTH;
2558
+ replayObjectData.accuracy = this.readShort(replayDataBuffer);
2559
+ const len = this.readByte(replayDataBuffer);
2190
2560
  if (len > 0) {
2191
2561
  const bytes = [];
2192
2562
  for (let j = 0; j < len; j++) {
2193
- bytes.push(replayDataBuffer.readInt8(bufferCounter));
2194
- bufferCounter += this.BYTE_LENGTH;
2563
+ bytes.push(this.readByte(replayDataBuffer));
2195
2564
  }
2196
2565
  // Int/int division in Java; numbers must be truncated to get actual number
2197
2566
  for (let j = 0; j < len * 8; j++) {
@@ -2201,249 +2570,138 @@ class ReplayAnalyzer {
2201
2570
  }
2202
2571
  }
2203
2572
  if (resultObject.replayVersion >= 1) {
2204
- replayObjectData.result =
2205
- replayDataBuffer.readInt8(bufferCounter);
2206
- bufferCounter += this.BYTE_LENGTH;
2573
+ replayObjectData.result = this.readByte(replayDataBuffer);
2207
2574
  }
2208
2575
  resultObject.hitObjectData.push(replayObjectData);
2209
2576
  }
2577
+ }
2578
+ parseOldReplayInformation(resultObject) {
2579
+ var _a, _b, _c;
2210
2580
  // Parse max combo, hit results, and accuracy in old replay version
2211
- if (resultObject.replayVersion < 3 && this.beatmap) {
2212
- let hit300 = 0;
2213
- let hit300k = 0;
2214
- let hit100 = 0;
2215
- let hit100k = 0;
2216
- let hit50 = 0;
2217
- let hit0 = 0;
2218
- let grantsGekiOrKatu = true;
2219
- const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2220
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2221
- ? this.beatmap.beatmap
2222
- : this.beatmap).hitObjects.objects;
2223
- for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2224
- // Hit result
2225
- const hitObjectData = resultObject.hitObjectData[i];
2226
- const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
2227
- switch (hitObjectData.result) {
2228
- case exports.HitResult.miss:
2229
- ++hit0;
2230
- grantsGekiOrKatu = false;
2231
- break;
2232
- case exports.HitResult.meh:
2233
- ++hit50;
2234
- grantsGekiOrKatu = false;
2235
- break;
2236
- case exports.HitResult.good:
2237
- ++hit100;
2238
- if (grantsGekiOrKatu && isNextNewCombo) {
2239
- ++hit100k;
2240
- }
2241
- break;
2242
- case exports.HitResult.great:
2243
- ++hit300;
2244
- if (grantsGekiOrKatu && isNextNewCombo) {
2245
- ++hit300k;
2246
- }
2247
- break;
2248
- }
2249
- if (isNextNewCombo) {
2250
- grantsGekiOrKatu = true;
2251
- }
2252
- }
2253
- resultObject.hit300k = hit300k;
2254
- resultObject.hit100k = hit100k;
2255
- resultObject.accuracy = new osuBase.Accuracy({
2256
- n300: hit300,
2257
- n100: hit100,
2258
- n50: hit50,
2259
- nmiss: hit0,
2260
- nobjects: hit300 + hit100 + hit50 + hit0,
2261
- });
2262
- // Determine rank
2263
- const totalHits = resultObject.accuracy.n300 +
2264
- resultObject.accuracy.n100 +
2265
- resultObject.accuracy.n50 +
2266
- resultObject.accuracy.nmiss;
2267
- const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
2268
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2269
- switch (true) {
2270
- case resultObject.accuracy.value() === 1:
2271
- if (isHidden) {
2272
- resultObject.rank = "XH";
2273
- }
2274
- else {
2275
- resultObject.rank = "X";
2276
- }
2277
- break;
2278
- case hit300Ratio > 0.9 &&
2279
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2280
- !resultObject.accuracy.nmiss:
2281
- if (isHidden) {
2282
- resultObject.rank = "SH";
2283
- }
2284
- else {
2285
- resultObject.rank = "S";
2286
- }
2581
+ if (resultObject.replayVersion >= 3) {
2582
+ return;
2583
+ }
2584
+ const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2585
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2586
+ ? this.beatmap.beatmap
2587
+ : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2588
+ let grantsGekiOrKatu = true;
2589
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2590
+ // Hit result
2591
+ const hitObjectData = resultObject.hitObjectData[i];
2592
+ const isNextNewCombo = objects
2593
+ ? i + 1 !== objects.length
2594
+ ? objects[i + 1].isNewCombo
2595
+ : true
2596
+ : false;
2597
+ switch (hitObjectData.result) {
2598
+ case exports.HitResult.miss:
2599
+ ++resultObject.accuracy.nmiss;
2600
+ grantsGekiOrKatu = false;
2287
2601
  break;
2288
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2289
- hit300Ratio > 0.9:
2290
- resultObject.rank = "A";
2602
+ case exports.HitResult.meh:
2603
+ ++resultObject.accuracy.n50;
2604
+ grantsGekiOrKatu = false;
2291
2605
  break;
2292
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2293
- hit300Ratio > 0.8:
2294
- resultObject.rank = "B";
2606
+ case exports.HitResult.good:
2607
+ ++resultObject.accuracy.n100;
2608
+ if (grantsGekiOrKatu && isNextNewCombo) {
2609
+ (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2610
+ ++resultObject.hit100k;
2611
+ }
2295
2612
  break;
2296
- case hit300Ratio > 0.6:
2297
- resultObject.rank = "C";
2613
+ case exports.HitResult.great:
2614
+ ++resultObject.accuracy.n300;
2615
+ if (grantsGekiOrKatu && isNextNewCombo) {
2616
+ (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2617
+ ++resultObject.hit300k;
2618
+ }
2298
2619
  break;
2299
- default:
2300
- resultObject.rank = "D";
2301
- }
2302
- }
2303
- this.data = new ReplayData(resultObject);
2304
- }
2305
- /**
2306
- * Gets hit error information of the replay.
2307
- *
2308
- * `analyze()` must be called before calling this.
2309
- */
2310
- calculateHitError() {
2311
- if (!this.data || !this.beatmap) {
2312
- return null;
2313
- }
2314
- const hitObjectData = this.data.hitObjectData;
2315
- let positiveCount = 0;
2316
- let negativeCount = 0;
2317
- let positiveTotal = 0;
2318
- let negativeTotal = 0;
2319
- const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2320
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2321
- ? this.beatmap.beatmap
2322
- : this.beatmap;
2323
- const objects = beatmap.hitObjects.objects;
2324
- const stats = new osuBase.MapStats({
2325
- od: beatmap.difficulty.od,
2326
- mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2327
- }).calculate();
2328
- const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2329
- // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2330
- // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2331
- //
2332
- // In such cases, the slider is skipped.
2333
- const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2334
- const accuracies = [];
2335
- for (let i = 0; i < hitObjectData.length; ++i) {
2336
- const v = hitObjectData[i];
2337
- const o = objects[i];
2338
- if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2339
- accuracies.push(0);
2340
- continue;
2341
- }
2342
- const accuracy = v.accuracy;
2343
- if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2344
- accuracies.push(0);
2345
- continue;
2346
- }
2347
- accuracies.push(accuracy);
2348
- if (accuracy >= 0) {
2349
- positiveTotal += accuracy;
2350
- ++positiveCount;
2351
2620
  }
2352
- else {
2353
- negativeTotal += accuracy;
2354
- ++negativeCount;
2621
+ if (isNextNewCombo) {
2622
+ grantsGekiOrKatu = true;
2355
2623
  }
2356
2624
  }
2357
- return {
2358
- positiveAvg: positiveTotal / positiveCount || 0,
2359
- negativeAvg: negativeTotal / negativeCount || 0,
2360
- unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2361
- };
2625
+ resultObject.rank = this.calculateRank(resultObject);
2362
2626
  }
2363
- /**
2364
- * Converts replay mods to droid mod string.
2365
- */
2366
- convertDroidMods(replayMods) {
2367
- const replayModsConstants = {
2368
- MOD_NOFAIL: "n",
2369
- MOD_EASY: "e",
2370
- MOD_HIDDEN: "h",
2371
- MOD_HARDROCK: "r",
2372
- MOD_DOUBLETIME: "d",
2373
- MOD_HALFTIME: "t",
2374
- MOD_NIGHTCORE: "c",
2375
- MOD_PRECISE: "s",
2376
- MOD_SMALLCIRCLE: "m",
2377
- MOD_SPEEDUP: "b",
2378
- MOD_REALLYEASY: "l",
2379
- MOD_PERFECT: "f",
2380
- MOD_SUDDENDEATH: "u",
2381
- MOD_SCOREV2: "v",
2382
- };
2383
- let modString = "";
2384
- for (const mod of replayMods) {
2385
- for (const property in replayModsConstants) {
2386
- if (!(property in replayModsConstants)) {
2387
- continue;
2388
- }
2389
- if (!mod.includes(property)) {
2390
- continue;
2391
- }
2392
- modString +=
2393
- replayModsConstants[property];
2394
- break;
2395
- }
2627
+ calculateRank(resultObject) {
2628
+ var _a, _b;
2629
+ const totalHits = resultObject.accuracy.n300 +
2630
+ resultObject.accuracy.n100 +
2631
+ resultObject.accuracy.n50 +
2632
+ resultObject.accuracy.nmiss;
2633
+ const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2634
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2635
+ switch (true) {
2636
+ case resultObject.accuracy.value() === 1:
2637
+ return isHidden ? "XH" : "X";
2638
+ case hit300Ratio > 0.9 &&
2639
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
2640
+ !resultObject.accuracy.nmiss:
2641
+ return isHidden ? "SH" : "S";
2642
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2643
+ hit300Ratio > 0.9:
2644
+ return "A";
2645
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2646
+ hit300Ratio > 0.8:
2647
+ return "B";
2648
+ case hit300Ratio > 0.6:
2649
+ return "C";
2650
+ default:
2651
+ return "D";
2396
2652
  }
2397
- return modString;
2398
2653
  }
2399
- /**
2400
- * Checks if a play is using 3 fingers.
2401
- *
2402
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2403
- */
2404
- checkFor3Finger() {
2405
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2406
- return;
2654
+ constructPlayableBeatmap() {
2655
+ var _a, _b;
2656
+ if (!this.beatmap || !this.data) {
2657
+ throw new Error("Beatmap and replay data must be defined.");
2658
+ }
2659
+ const mods = this.data.isReplayV3()
2660
+ ? this.data.convertedMods.slice()
2661
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.slice()) !== null && _b !== void 0 ? _b : [];
2662
+ if (this.data.isReplayV5() &&
2663
+ [
2664
+ this.data.forceCS,
2665
+ this.data.forceAR,
2666
+ this.data.forceOD,
2667
+ this.data.forceHP,
2668
+ ].some((v) => v !== undefined)) {
2669
+ mods.push(new osuBase.ModDifficultyAdjust({
2670
+ cs: this.data.forceCS,
2671
+ ar: this.data.forceAR,
2672
+ od: this.data.forceOD,
2673
+ hp: this.data.forceHP,
2674
+ }));
2407
2675
  }
2408
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap instanceof osuBase.Beatmap
2676
+ return (this.beatmap instanceof osuBase.Beatmap
2409
2677
  ? this.beatmap
2410
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2411
- const result = threeFingerChecker.check();
2412
- this.is3Finger = result.is3Finger;
2413
- this.tapPenalty = result.penalty;
2414
- this.hasBeenCheckedFor3Finger = true;
2678
+ : this.beatmap.beatmap).createPlayableBeatmap({
2679
+ mode: osuBase.Modes.droid,
2680
+ mods: mods,
2681
+ customSpeedMultiplier: this.data.isReplayV4()
2682
+ ? this.data.speedMultiplier
2683
+ : 1,
2684
+ });
2415
2685
  }
2416
- /**
2417
- * Checks if a play is using 2 hands.
2418
- *
2419
- * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2420
- */
2421
- checkFor2Hand() {
2422
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2423
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2424
- !this.data) {
2425
- return;
2426
- }
2427
- const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2428
- const result = twoHandChecker.check();
2429
- this.is2Hand = result.is2Hand;
2430
- this.twoHandedNoteCount = result.twoHandedNoteCount;
2431
- this.hasBeenCheckedFor2Hand = true;
2686
+ readByte(buffer) {
2687
+ const num = buffer.readInt8(this.bufferOffset);
2688
+ this.bufferOffset += 1;
2689
+ return num;
2432
2690
  }
2433
- /**
2434
- * Checks if a play has cheesed sliders.
2435
- *
2436
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2437
- */
2438
- checkForSliderCheesing() {
2439
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2440
- return;
2441
- }
2442
- const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
2443
- ? this.beatmap
2444
- : this.beatmap.beatmap, this.data, this.difficultyAttributes);
2445
- this.sliderCheesePenalty = sliderCheeseChecker.check();
2446
- this.hasBeenCheckedForSliderCheesing = true;
2691
+ readShort(buffer) {
2692
+ const num = buffer.readInt16BE(this.bufferOffset);
2693
+ this.bufferOffset += 2;
2694
+ return num;
2695
+ }
2696
+ readInt(buffer) {
2697
+ const num = buffer.readInt32BE(this.bufferOffset);
2698
+ this.bufferOffset += 4;
2699
+ return num;
2700
+ }
2701
+ readFloat(buffer) {
2702
+ const num = buffer.readFloatBE(this.bufferOffset);
2703
+ this.bufferOffset += 4;
2704
+ return num;
2447
2705
  }
2448
2706
  }
2449
2707
 
@@ -2455,33 +2713,6 @@ class ReplayAnalyzer {
2455
2713
  * This is used when analyzing replays using replay analyzer.
2456
2714
  */
2457
2715
  class ReplayObjectData {
2458
- /**
2459
- * 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)).
2460
- *
2461
- * For sliders, this is the offset at which the slider head was hit. For
2462
- * 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)).
2463
- *
2464
- * For spinners, this is the total amount at which the spinner was spinned:
2465
- * ```js
2466
- * const rotations = Math.floor(data.accuracy / 4);
2467
- * ```
2468
- * The remainder of the division denotes the hit result of the spinner:
2469
- * - `HitResult.great`: 3
2470
- * - `HitResult.good`: 2
2471
- * - `HitResult.meh`: 1
2472
- * - `HitResult.miss`: 0
2473
- */
2474
- accuracy;
2475
- /**
2476
- * The tickset of the hitobject.
2477
- *
2478
- * This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
2479
- */
2480
- tickset;
2481
- /**
2482
- * The bitwise hit result of the hitobject.
2483
- */
2484
- result;
2485
2716
  constructor(values) {
2486
2717
  this.accuracy = values.accuracy;
2487
2718
  this.tickset = values.tickset;
@@ -2492,9 +2723,13 @@ class ReplayObjectData {
2492
2723
  exports.CursorData = CursorData;
2493
2724
  exports.CursorOccurrence = CursorOccurrence;
2494
2725
  exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
2726
+ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2495
2727
  exports.ReplayAnalyzer = ReplayAnalyzer;
2496
2728
  exports.ReplayData = ReplayData;
2497
2729
  exports.ReplayObjectData = ReplayObjectData;
2730
+ exports.ReplayV3Data = ReplayV3Data;
2731
+ exports.ReplayV4Data = ReplayV4Data;
2732
+ exports.ReplayV5Data = ReplayV5Data;
2498
2733
  exports.SliderCheeseChecker = SliderCheeseChecker;
2499
2734
  exports.ThreeFingerChecker = ThreeFingerChecker;
2500
2735
  exports.TwoHandChecker = TwoHandChecker;