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