Open sourced mari module

This commit is contained in:
nise.moe 2024-03-06 23:46:21 +01:00
parent ca9a43c06c
commit 402c89b20d
17 changed files with 389 additions and 194 deletions

112
mari/pom.xml Normal file
View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.nisemoe</groupId>
<artifactId>mari</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<kotlin.version>1.9.22</kotlin.version>
</properties>
<build>
<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<mainClass>MainKt</mainClass>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<!-- For Brotli compression of Judgement data -->
<dependency>
<groupId>com.aayushatharva.brotli4j</groupId>
<artifactId>brotli4j</artifactId>
<version>1.16.0</version>
</dependency>
<!-- For LZMA decompression of replay data -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.25.0</version>
</dependency>
<dependency>
<groupId>org.tukaani</groupId>
<artifactId>xz</artifactId>
<version>1.9</version>
</dependency>
<!-- For JSON serialization -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>1.6.3</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</project>

29
mari/readme.md Normal file
View File

@ -0,0 +1,29 @@
# mari
>osu! utility lib in kotlin to manipulate replays and judgement data in a safe and performant way.
This module allows [nise.moe](https://nise.moe) to juggle a ton of replays and data around.
# Usage
### Compress / decompress judgement data
The `Judgement` data class ought to represent the way a player has played a specific beatmap. It contains the hit error, distance, etc for each hit object. The structure is based off the Circleguard `Investigations.judgements` return type.
You can use `CompressJudgements.compress` and `CompressJudgements.decompress` to losslessly store and retrieve judgement data. According to my estimates, the compressed data is about 33% the size of the original data.
### Decode a replay
>This method is fundamentally written with the assumption that it'll be used on user-provided replays. It is thus designed to be safe and to not crash on invalid replays.
`OsuReplay` allows you to safely decode an `.osr` file. Once you've parsed the file, you can instantiate a new class:
```kotlin
val replay = OsuReplay(replayFile.bytes)
```
If everything goes well, you can then start reading the replay data.
```kotlin
println(replay.playerName) // mrekk
```

View File

@ -0,0 +1,111 @@
package org.nisemoe.mari.judgements
import com.aayushatharva.brotli4j.Brotli4jLoader
import com.aayushatharva.brotli4j.decoder.Decoder
import com.aayushatharva.brotli4j.encoder.Encoder
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import kotlin.math.round
/**
* Compresses and decompresses score judgements with lossless accuracy.
*/
class CompressJudgements {
companion object {
init {
Brotli4jLoader.ensureAvailability()
}
private val brotliParameters: Encoder.Parameters = Encoder.Parameters()
.setQuality(11)
/**
* Writes a variable-length quantity to the buffer.
* See: https://en.wikipedia.org/wiki/Variable-length_quantity
*/
private fun ByteBuffer.putVLQ(value: Int) {
var currentValue = value
do {
var temp = (currentValue and 0x7F)
currentValue = currentValue ushr 7
if (currentValue != 0) {
temp = temp or 0x80
}
this.put(temp.toByte())
} while (currentValue != 0)
}
/**
* Reads a variable-length quantity from the buffer.
* See: https://en.wikipedia.org/wiki/Variable-length_quantity
*/
private fun ByteBuffer.getVLQ(): Int {
var result = 0
var shift = 0
var b: Byte
do {
b = this.get()
result = result or ((b.toInt() and 0x7F) shl shift)
shift += 7
} while (b.toInt() and 0x80 != 0)
return result
}
fun compress(judgements: List<Judgement>): ByteArray {
val byteStream = ByteArrayOutputStream()
var lastTimestamp = 0.0
judgements.forEach { judgement ->
byteStream.use { stream ->
/**
* We allocate an arbitrary amount of buffer which *hopefully* is enough.
*/
ByteBuffer.allocate(4096).let { buffer ->
buffer.putVLQ((judgement.time - lastTimestamp).toInt())
buffer.putVLQ(round(judgement.x * 100).toInt())
buffer.putVLQ(round(judgement.y * 100).toInt())
buffer.put(judgement.type.ordinal.toByte())
buffer.putVLQ((judgement.distanceToCenter * 100).toInt())
buffer.putVLQ((judgement.distanceToEdge * 100).toInt())
buffer.putVLQ(judgement.error.toInt())
lastTimestamp = judgement.time
stream.write(buffer.array(), 0, buffer.position())
}
}
}
return Encoder.compress(byteStream.toByteArray(), brotliParameters)
}
fun decompress(compressedData: ByteArray): List<Judgement> {
val data = Decoder.decompress(compressedData).decompressedData ?: return emptyList()
val buffer = ByteBuffer.wrap(data)
val judgements = mutableListOf<Judgement>()
var lastTime = 0.0
while (buffer.hasRemaining()) {
val deltaTime = buffer.getVLQ()
lastTime += deltaTime
judgements.add(
Judgement(
time = lastTime,
x = buffer.getVLQ() / 100.0,
y = buffer.getVLQ() / 100.0,
type = Judgement.Type.entries[buffer.get().toInt()],
distanceToCenter = buffer.getVLQ() / 100.0,
distanceToEdge = buffer.getVLQ() / 100.0,
error = buffer.getVLQ().toDouble()
))
}
return judgements
}
}
}

View File

@ -0,0 +1,45 @@
package org.nisemoe.mari.judgements
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a judgement on a hit object.
* The structure *might* seem arbitrary but it's more or less what Circleguard provides.
*/
@Serializable
data class Judgement(
val time: Double,
val x: Double,
val y: Double,
val type: Type,
@SerialName("distance_center")
val distanceToCenter: Double,
@SerialName("distance_edge")
val distanceToEdge: Double,
val error: Double
) {
@Serializable
enum class Type {
@SerialName("Hit300")
THREE_HUNDRED,
@SerialName("Hit100")
ONE_HUNDRED,
@SerialName("Hit50")
FIFTY,
@SerialName("Miss")
MISS
}
}

View File

@ -1,4 +1,4 @@
package com.nisemoe.nise.osu package org.nisemoe.mari.replays
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
import org.apache.commons.compress.compressors.lzma.LZMACompressorOutputStream import org.apache.commons.compress.compressors.lzma.LZMACompressorOutputStream
@ -9,14 +9,21 @@ import java.nio.ByteOrder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.* import java.util.*
/**
* Decodes an osu! replay file from a byte array.
*/
class OsuReplay(fileContent: ByteArray) { class OsuReplay(fileContent: ByteArray) {
companion object { companion object {
// ~4mb /**
* We restrict the maximum replay file size to roughly 4MB.
*/
private val EXPECTED_FILE_SIZE = 0 .. 4194304 private val EXPECTED_FILE_SIZE = 0 .. 4194304
// ~512kb /**
* We restrict the maximum string length to roughly 500KB.
*/
private const val MAX_STRING_LENGTH = 512000 private const val MAX_STRING_LENGTH = 512000
private val EXPECTED_STRING_LENGTH = 0 .. MAX_STRING_LENGTH private val EXPECTED_STRING_LENGTH = 0 .. MAX_STRING_LENGTH
@ -64,9 +71,6 @@ class OsuReplay(fileContent: ByteArray) {
private fun decode() { private fun decode() {
try { try {
gameMode = dis.readByte().toInt() gameMode = dis.readByte().toInt()
if(gameMode != 0) {
throw SecurityException("Invalid game mode")
}
gameVersion = readIntLittleEndian() gameVersion = readIntLittleEndian()
beatmapHash = dis.readCompressedReplayData() beatmapHash = dis.readCompressedReplayData()
@ -109,22 +113,18 @@ class OsuReplay(fileContent: ByteArray) {
} }
private fun DataInputStream.readCompressedReplayData(length: Int): String { private fun DataInputStream.readCompressedReplayData(length: Int): String {
// Read the compressed data
val compressedData = ByteArray(length) val compressedData = ByteArray(length)
readFully(compressedData) readFully(compressedData)
// Decompress the data val compressedOutputStream = ByteArrayOutputStream().use { outputStream ->
val decompressedStream = LZMACompressorInputStream(compressedData.inputStream()) LZMACompressorInputStream(compressedData.inputStream()).use { decompressedStream ->
val decompressedData = decompressedStream.readBytes() LZMACompressorOutputStream(outputStream).use { lzmaCompressorOutputStream ->
decompressedStream.close() decompressedStream.copyTo(lzmaCompressorOutputStream)
}
}
outputStream
}
// Compress the decompressed data
val compressedOutputStream = ByteArrayOutputStream()
val lzmaCompressorOutputStream = LZMACompressorOutputStream(compressedOutputStream)
lzmaCompressorOutputStream.write(decompressedData)
lzmaCompressorOutputStream.close()
// Now encode the re-compressed data to Base64
return Base64.getEncoder().encodeToString(compressedOutputStream.toByteArray()) return Base64.getEncoder().encodeToString(compressedOutputStream.toByteArray())
} }

View File

@ -0,0 +1,30 @@
package org.nisemoe.mari.judgements
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class CompressJudgementsTest {
@Test
fun testCompress() {
val judgements = listOf(
Judgement(time = 1.0, x = 1.0, y = 1.0, type = Judgement.Type.THREE_HUNDRED, distanceToCenter = 1.0, distanceToEdge = 1.0, error = 1.0),
Judgement(time = 2.0, x = 2.0, y = 2.0, type = Judgement.Type.THREE_HUNDRED, distanceToCenter = 2.0, distanceToEdge = 2.0, error = 2.0)
)
val compressedData = CompressJudgements.compress(judgements)
assertTrue(compressedData.isNotEmpty())
}
@Test
fun testCompressAndDecompress() {
val originalJudgements = listOf(
Judgement(time = 1.123456789123456, x = 1.0, y = 1.0, type = Judgement.Type.THREE_HUNDRED, distanceToCenter = 1.0, distanceToEdge = 1.0, error = 1.0),
Judgement(time = 2.123456789123456, x = 2.0, y = 2.0, type = Judgement.Type.THREE_HUNDRED, distanceToCenter = 2.0, distanceToEdge = 2.0, error = 2.0)
)
val compressedData = CompressJudgements.compress(originalJudgements)
val decompressedJudgements = CompressJudgements.decompress(compressedData)
assertEquals(originalJudgements, decompressedJudgements)
}
}

View File

@ -64,6 +64,11 @@
<artifactId>konata</artifactId> <artifactId>konata</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
</dependency> </dependency>
<dependency>
<groupId>org.nisemoe</groupId>
<artifactId>mari</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId> <groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId> <artifactId>jackson-dataformat-xml</artifactId>

View File

@ -1,7 +1,7 @@
package com.nisemoe.nise package com.nisemoe.nise
import com.nisemoe.generated.enums.JudgementType import com.nisemoe.generated.enums.JudgementType
import com.nisemoe.nise.integrations.CircleguardService import org.nisemoe.mari.judgements.Judgement
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -25,12 +25,12 @@ class Format {
return Date.from(localDateTime.atZone(ZoneOffset.UTC).toInstant()) return Date.from(localDateTime.atZone(ZoneOffset.UTC).toInstant())
} }
fun fromJudgementType(circleGuardJudgementType: CircleguardService.JudgementType): JudgementType { fun fromJudgementType(circleGuardJudgementType: Judgement.Type): JudgementType {
return when (circleGuardJudgementType) { return when (circleGuardJudgementType) {
CircleguardService.JudgementType.THREE_HUNDRED -> JudgementType.`300` Judgement.Type.THREE_HUNDRED -> JudgementType.`300`
CircleguardService.JudgementType.ONE_HUNDRED -> JudgementType.`100` Judgement.Type.ONE_HUNDRED -> JudgementType.`100`
CircleguardService.JudgementType.FIFTY -> JudgementType.`50` Judgement.Type.FIFTY -> JudgementType.`50`
CircleguardService.JudgementType.MISS -> JudgementType.Miss Judgement.Type.MISS -> JudgementType.Miss
} }
} }

View File

@ -1,9 +1,8 @@
package com.nisemoe.nise package com.nisemoe.nise
import com.nisemoe.nise.integrations.CircleguardService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.nisemoe.mari.judgements.Judgement
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID
data class UserQueueDetails( data class UserQueueDetails(
val isProcessing: Boolean, val isProcessing: Boolean,
@ -100,7 +99,7 @@ data class ReplayViewerData(
val beatmap: String, val beatmap: String,
val replay: String, val replay: String,
val mods: Int, val mods: Int,
val judgements: List<CircleguardService.ScoreJudgement> val judgements: List<Judgement>
) )
data class ReplayPairViewerData( data class ReplayPairViewerData(
@ -108,8 +107,8 @@ data class ReplayPairViewerData(
val replay1: String, val replay1: String,
val replay2: String, val replay2: String,
val mods: Int, val mods: Int,
val judgements1: List<CircleguardService.ScoreJudgement>, val judgements1: List<Judgement>,
val judgements2: List<CircleguardService.ScoreJudgement> val judgements2: List<Judgement>
) )
data class ReplayData( data class ReplayData(

View File

@ -10,10 +10,10 @@ import com.nisemoe.konata.compareSingleReplayWithSet
import com.nisemoe.nise.database.BeatmapService import com.nisemoe.nise.database.BeatmapService
import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.osu.OsuReplay
import com.nisemoe.nise.scheduler.ImportScores import com.nisemoe.nise.scheduler.ImportScores
import com.nisemoe.nise.service.CompressJudgements
import org.jooq.DSLContext import org.jooq.DSLContext
import org.nisemoe.mari.judgements.CompressJudgements
import org.nisemoe.mari.replays.OsuReplay
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@ -26,7 +26,6 @@ import java.time.OffsetDateTime
class UploadReplayController( class UploadReplayController(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val beatmapService: BeatmapService, private val beatmapService: BeatmapService,
private val compressJudgements: CompressJudgements,
private val circleguardService: CircleguardService, private val circleguardService: CircleguardService,
private val osuApi: OsuApi private val osuApi: OsuApi
) { ) {
@ -175,7 +174,7 @@ class UploadReplayController(
.set(USER_SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, analysis.keypresses_standard_deviation_adjusted) .set(USER_SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, analysis.keypresses_standard_deviation_adjusted)
.set(USER_SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, analysis.sliderend_release_median_adjusted) .set(USER_SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, analysis.sliderend_release_median_adjusted)
.set(USER_SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, analysis.sliderend_release_standard_deviation_adjusted) .set(USER_SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, analysis.sliderend_release_standard_deviation_adjusted)
.set(SCORES.JUDGEMENTS, compressJudgements.serialize(analysis.judgements)) .set(SCORES.JUDGEMENTS, CompressJudgements.compress(analysis.judgements))
.returning(USER_SCORES.ID) .returning(USER_SCORES.ID)
.fetchOne() .fetchOne()

View File

@ -9,13 +9,14 @@ import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.AuthService
import com.nisemoe.nise.service.CompressJudgements
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
import org.jooq.Condition import org.jooq.Condition
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
import org.jooq.impl.DSL import org.jooq.impl.DSL
import org.jooq.impl.DSL.avg import org.jooq.impl.DSL.avg
import org.nisemoe.mari.judgements.CompressJudgements
import org.nisemoe.mari.judgements.Judgement
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@ -25,10 +26,8 @@ import kotlin.math.roundToInt
@Service @Service
class ScoreService( class ScoreService(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val beatmapService: BeatmapService,
private val authService: AuthService, private val authService: AuthService,
private val osuApi: OsuApi, private val osuApi: OsuApi
private val compressJudgements: CompressJudgements
) { ) {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
@ -502,16 +501,16 @@ class ScoreService(
replayData.comparable_adjusted_ur = otherScores.get("avg_adjusted_ur", Double::class.java) replayData.comparable_adjusted_ur = otherScores.get("avg_adjusted_ur", Double::class.java)
} }
fun mapLegacyJudgement(judgementType: JudgementType): CircleguardService.JudgementType { fun mapLegacyJudgement(judgementType: JudgementType): Judgement.Type {
return when(judgementType) { return when(judgementType) {
JudgementType.Miss -> CircleguardService.JudgementType.MISS JudgementType.Miss -> Judgement.Type.MISS
JudgementType.`300` -> CircleguardService.JudgementType.THREE_HUNDRED JudgementType.`300` -> Judgement.Type.THREE_HUNDRED
JudgementType.`100` -> CircleguardService.JudgementType.ONE_HUNDRED JudgementType.`100` -> Judgement.Type.ONE_HUNDRED
JudgementType.`50` -> CircleguardService.JudgementType.FIFTY JudgementType.`50` -> Judgement.Type.FIFTY
} }
} }
fun getJudgements(replayId: Long): List<CircleguardService.ScoreJudgement> { fun getJudgements(replayId: Long): List<Judgement> {
val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS) val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS)
.from(SCORES) .from(SCORES)
.where(SCORES.REPLAY_ID.eq(replayId)) .where(SCORES.REPLAY_ID.eq(replayId))
@ -527,19 +526,19 @@ class ScoreService(
.where(SCORES_JUDGEMENTS.SCORE_ID.eq(scoreId)) .where(SCORES_JUDGEMENTS.SCORE_ID.eq(scoreId))
.fetchInto(ScoresJudgementsRecord::class.java) .fetchInto(ScoresJudgementsRecord::class.java)
return judgementRecords.map { return judgementRecords.map {
CircleguardService.ScoreJudgement( Judgement(
x = it.x!!, x = it.x!!,
y = it.y!!, y = it.y!!,
error = it.error!!, error = it.error!!,
distance_center = it.distanceCenter!!, distanceToCenter = it.distanceCenter!!,
distance_edge = it.distanceEdge!!, distanceToEdge = it.distanceEdge!!,
time = it.time!!, time = it.time!!,
type = mapLegacyJudgement(it.type!!) type = mapLegacyJudgement(it.type!!)
) )
} }
} }
return compressJudgements.deserialize(judgementsRecord.judgements!!) return CompressJudgements.decompress(judgementsRecord.judgements!!)
} }
fun getHitDistribution(scoreId: Int): Map<Int, DistributionEntry> { fun getHitDistribution(scoreId: Int): Map<Int, DistributionEntry> {
@ -552,7 +551,7 @@ class ScoreService(
return this.getHitDistributionLegacy(scoreId) return this.getHitDistributionLegacy(scoreId)
} }
val judgements = compressJudgements.deserialize(judgementsRecord.judgements!!) val judgements = CompressJudgements.decompress(judgementsRecord.judgements!!)
val errorDistribution = mutableMapOf<Int, MutableMap<String, Int>>() val errorDistribution = mutableMapOf<Int, MutableMap<String, Int>>()
var totalHits = 0 var totalHits = 0

View File

@ -6,8 +6,8 @@ import com.nisemoe.nise.Format
import com.nisemoe.nise.ReplayData import com.nisemoe.nise.ReplayData
import com.nisemoe.nise.ReplayDataSimilarScore import com.nisemoe.nise.ReplayDataSimilarScore
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.CompressJudgements
import org.jooq.DSLContext import org.jooq.DSLContext
import org.nisemoe.mari.judgements.CompressJudgements
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
@ -16,8 +16,7 @@ import kotlin.math.roundToInt
@Service @Service
class UserScoreService( class UserScoreService(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val scoreService: ScoreService, private val scoreService: ScoreService
private val compressJudgements: CompressJudgements
) { ) {
fun getReplayData(replayId: UUID): ReplayData? { fun getReplayData(replayId: UUID): ReplayData? {
@ -165,7 +164,7 @@ class UserScoreService(
} }
fun getHitDistribution(compressedJudgements: ByteArray): Map<Int, DistributionEntry> { fun getHitDistribution(compressedJudgements: ByteArray): Map<Int, DistributionEntry> {
val judgements = compressJudgements.deserialize(compressedJudgements) val judgements = CompressJudgements.decompress(compressedJudgements)
val errorDistribution = mutableMapOf<Int, MutableMap<String, Int>>() val errorDistribution = mutableMapOf<Int, MutableMap<String, Int>>()
var totalHits = 0 var totalHits = 0

View File

@ -2,9 +2,9 @@ package com.nisemoe.nise.integrations
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.scheduler.ImportScores import com.nisemoe.nise.scheduler.ImportScores
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.nisemoe.mari.judgements.Judgement
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.net.URI import java.net.URI
@ -32,32 +32,6 @@ class CircleguardService {
val mods: Int val mods: Int
) )
@Serializable
data class ScoreJudgement(
val time: Double,
val x: Double,
val y: Double,
val type: JudgementType,
val distance_center: Double,
val distance_edge: Double,
val error: Double
)
@Serializable
enum class JudgementType {
@SerialName("Hit300")
THREE_HUNDRED,
@SerialName("Hit100")
ONE_HUNDRED,
@SerialName("Hit50")
FIFTY,
@SerialName("Miss")
MISS
}
@Serializable @Serializable
data class ReplayResponse( data class ReplayResponse(
val ur: Double?, val ur: Double?,
@ -87,7 +61,7 @@ class CircleguardService {
val sliderend_release_standard_deviation: Double?, val sliderend_release_standard_deviation: Double?,
val sliderend_release_standard_deviation_adjusted: Double?, val sliderend_release_standard_deviation_adjusted: Double?,
val judgements: List<ScoreJudgement> val judgements: List<Judgement>
) )
fun postProcessReplay(replayResponse: ReplayResponse, mods: Int = 0) { fun postProcessReplay(replayResponse: ReplayResponse, mods: Int = 0) {

View File

@ -5,13 +5,13 @@ import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.service.CompressJudgements
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jooq.DSLContext import org.jooq.DSLContext
import org.nisemoe.mari.judgements.CompressJudgements
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile import org.springframework.context.annotation.Profile
@ -24,8 +24,7 @@ import java.time.OffsetDateTime
class FixOldScores( class FixOldScores(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val osuApi: OsuApi, private val osuApi: OsuApi,
private val circleguardService: CircleguardService, private val circleguardService: CircleguardService
private val compressJudgements: CompressJudgements
){ ){
companion object { companion object {
@ -179,7 +178,7 @@ class FixOldScores(
.set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted) .set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted)
.set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation) .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation)
.set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted) .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted)
.set(SCORES.JUDGEMENTS, compressJudgements.serialize(processedReplay.judgements)) .set(SCORES.JUDGEMENTS, CompressJudgements.compress(processedReplay.judgements))
.where(SCORES.REPLAY_ID.eq(score.replayId)) .where(SCORES.REPLAY_ID.eq(score.replayId))
.returningResult(SCORES.ID) .returningResult(SCORES.ID)
.fetchOne()?.getValue(SCORES.ID) .fetchOne()?.getValue(SCORES.ID)

View File

@ -15,11 +15,11 @@ import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.osu.OsuApiModels import com.nisemoe.nise.osu.OsuApiModels
import com.nisemoe.nise.service.CacheService import com.nisemoe.nise.service.CacheService
import com.nisemoe.nise.service.CompressJudgements
import com.nisemoe.nise.service.UpdateUserQueueService import com.nisemoe.nise.service.UpdateUserQueueService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Query import org.jooq.Query
import org.nisemoe.mari.judgements.CompressJudgements
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.InitializingBean import org.springframework.beans.factory.InitializingBean
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@ -48,8 +48,7 @@ class ImportScores(
private val scoreService: ScoreService, private val scoreService: ScoreService,
private val updateUserQueueService: UpdateUserQueueService, private val updateUserQueueService: UpdateUserQueueService,
private val circleguardService: CircleguardService, private val circleguardService: CircleguardService,
private val messagingTemplate: SimpMessagingTemplate, private val messagingTemplate: SimpMessagingTemplate
private val compressJudgements: CompressJudgements
) : InitializingBean { ) : InitializingBean {
private val userToUpdateBucket = mutableListOf<Long>() private val userToUpdateBucket = mutableListOf<Long>()
@ -760,7 +759,7 @@ class ImportScores(
.set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted) .set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted)
.set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation) .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation)
.set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted) .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted)
.set(SCORES.JUDGEMENTS, compressJudgements.serialize(processedReplay.judgements)) .set(SCORES.JUDGEMENTS, CompressJudgements.compress(processedReplay.judgements))
.where(SCORES.REPLAY_ID.eq(score.best_id)) .where(SCORES.REPLAY_ID.eq(score.best_id))
.returningResult(SCORES.ID) .returningResult(SCORES.ID)
.fetchOne()?.getValue(SCORES.ID) .fetchOne()?.getValue(SCORES.ID)

View File

@ -1,99 +0,0 @@
package com.nisemoe.nise.service
import com.aayushatharva.brotli4j.Brotli4jLoader
import com.aayushatharva.brotli4j.decoder.Decoder
import com.aayushatharva.brotli4j.encoder.Encoder
import com.nisemoe.nise.integrations.CircleguardService
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import kotlin.math.round
@Service
class CompressJudgements {
val brotliParameters: Encoder.Parameters = Encoder.Parameters()
.setQuality(11)
init {
Brotli4jLoader.ensureAvailability()
}
fun ByteBuffer.putVLQ(value: Int) {
var currentValue = value
do {
var temp = (currentValue and 0x7F)
currentValue = currentValue ushr 7
if (currentValue != 0) {
temp = temp or 0x80
}
this.put(temp.toByte())
} while (currentValue != 0)
}
fun ByteBuffer.getVLQ(): Int {
var result = 0
var shift = 0
var b: Byte
do {
b = this.get()
result = result or ((b.toInt() and 0x7F) shl shift)
shift += 7
} while (b.toInt() and 0x80 != 0)
return result
}
fun serialize(judgements: List<CircleguardService.ScoreJudgement>): ByteArray {
val byteStream = ByteArrayOutputStream()
var lastTimestamp = 0.0
judgements.forEach { judgement ->
byteStream.use { stream ->
/**
* We allocate an arbitrary amount of buffer which *hopefully* is enough.
*/
ByteBuffer.allocate(4096).let { buffer ->
buffer.putVLQ((judgement.time - lastTimestamp).toInt())
buffer.putVLQ(round(judgement.x * 100).toInt())
buffer.putVLQ(round(judgement.y * 100).toInt())
buffer.put(judgement.type.ordinal.toByte())
buffer.putVLQ((judgement.distance_center * 100).toInt())
buffer.putVLQ((judgement.distance_edge * 100).toInt())
buffer.putVLQ(judgement.error.toInt())
lastTimestamp = judgement.time
stream.write(buffer.array(), 0, buffer.position())
}
}
}
return Encoder.compress(byteStream.toByteArray(), brotliParameters)
}
fun deserialize(compressedData: ByteArray): List<CircleguardService.ScoreJudgement> {
val data = Decoder.decompress(compressedData).decompressedData ?: return emptyList()
val buffer = ByteBuffer.wrap(data)
val judgements = mutableListOf<CircleguardService.ScoreJudgement>()
var lastTime = 0.0
while (buffer.hasRemaining()) {
val deltaTime = buffer.getVLQ()
lastTime += deltaTime
judgements.add(
CircleguardService.ScoreJudgement(
time = lastTime,
x = buffer.getVLQ() / 100.0,
y = buffer.getVLQ() / 100.0,
type = CircleguardService.JudgementType.entries[buffer.get().toInt()],
distance_center = buffer.getVLQ() / 100.0,
distance_edge = buffer.getVLQ() / 100.0,
error = buffer.getVLQ().toDouble()
))
}
return judgements
}
}

View File

@ -5,11 +5,7 @@ import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.nise.database.UserService import com.nisemoe.nise.database.UserService
import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.CompressJudgements
import com.nisemoe.nise.osu.TokenService
import com.nisemoe.nise.service.AuthService
import com.nisemoe.nise.service.CacheService
import com.nisemoe.nise.service.CompressJudgements
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jooq.DSLContext import org.jooq.DSLContext
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*
@ -17,10 +13,8 @@ import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
@SpringBootTest @SpringBootTest