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