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