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