Add ViaAxiom
Deploy / Build (push) Skipped
Pull Request Build / Build (pull_request) Failing after 17s
Deploy / Build (push) Skipped
Pull Request Build / Build (pull_request) Failing after 17s
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
base {
|
||||
archivesName.set("viaaxiom-core")
|
||||
}
|
||||
|
||||
// Velocity can run on Java 21, so the shared core cannot target the Fabric mod's Java 25.
|
||||
val targetJavaVersion = 21
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api("com.github.luben:zstd-jni:1.5.5-4")
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(targetJavaVersion.toString()))
|
||||
freeCompilerArgs.add("-Xjdk-release=$targetJavaVersion")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.release.set(targetJavaVersion)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package me.wargearsystems.viaaxiom.binary
|
||||
|
||||
import java.lang.invoke.MethodHandles
|
||||
import java.lang.invoke.VarHandle
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class AxiomByteReader(
|
||||
private val bytes: ByteArray,
|
||||
) {
|
||||
var readerIndex: Int = 0
|
||||
private set
|
||||
|
||||
val readableBytes: Int
|
||||
get() = bytes.size - readerIndex
|
||||
|
||||
fun isReadable(): Boolean = readableBytes > 0
|
||||
|
||||
fun readByte(): Byte {
|
||||
requireReadable(1)
|
||||
return bytes[readerIndex++]
|
||||
}
|
||||
|
||||
fun readUnsignedByte(): Int = readByte().toInt() and 0xFF
|
||||
|
||||
fun readBoolean(): Boolean = readUnsignedByte() != 0
|
||||
|
||||
fun readShort(): Short {
|
||||
requireReadable(Short.SIZE_BYTES)
|
||||
val value = ((bytes[readerIndex].toInt() and 0xFF) shl 8) or
|
||||
(bytes[readerIndex + 1].toInt() and 0xFF)
|
||||
readerIndex += Short.SIZE_BYTES
|
||||
return value.toShort()
|
||||
}
|
||||
|
||||
fun readInt(): Int {
|
||||
requireReadable(Int.SIZE_BYTES)
|
||||
val value = ((bytes[readerIndex].toInt() and 0xFF) shl 24) or
|
||||
((bytes[readerIndex + 1].toInt() and 0xFF) shl 16) or
|
||||
((bytes[readerIndex + 2].toInt() and 0xFF) shl 8) or
|
||||
(bytes[readerIndex + 3].toInt() and 0xFF)
|
||||
readerIndex += Int.SIZE_BYTES
|
||||
return value
|
||||
}
|
||||
|
||||
fun readLong(): Long {
|
||||
requireReadable(Long.SIZE_BYTES)
|
||||
var value = 0L
|
||||
repeat(Long.SIZE_BYTES) {
|
||||
value = (value shl 8) or (bytes[readerIndex++].toLong() and 0xFFL)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun readLongArray(): LongArray {
|
||||
return readLongArray(readVarInt())
|
||||
}
|
||||
|
||||
fun readLongArray(length: Int): LongArray {
|
||||
if (length < 0) {
|
||||
throw AxiomByteCodecException("Long array length cannot be negative: $length")
|
||||
}
|
||||
|
||||
if (length > readableBytes / Long.SIZE_BYTES) {
|
||||
throw AxiomByteCodecException("Long array exceeds remaining packet bytes")
|
||||
}
|
||||
val values = LongArray(length)
|
||||
for (index in values.indices) {
|
||||
values[index] = BIG_ENDIAN_LONGS.get(bytes, readerIndex) as Long
|
||||
readerIndex += Long.SIZE_BYTES
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
fun readVarInt(): Int {
|
||||
var value = 0
|
||||
var position = 0
|
||||
|
||||
while (position < 32) {
|
||||
val currentByte = readUnsignedByte()
|
||||
value = value or ((currentByte and 0x7F) shl position)
|
||||
|
||||
if ((currentByte and 0x80) == 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
position += 7
|
||||
}
|
||||
|
||||
throw AxiomByteCodecException("VarInt is too large")
|
||||
}
|
||||
|
||||
fun readByteArray(maxLength: Int = Int.MAX_VALUE): ByteArray {
|
||||
val length = readVarInt()
|
||||
if (length > maxLength) {
|
||||
throw AxiomByteCodecException("Byte array is too long: $length bytes")
|
||||
}
|
||||
return readBytes(length)
|
||||
}
|
||||
|
||||
fun readUtf(maxLength: Int = 32767): String {
|
||||
val byteLength = readVarInt()
|
||||
if (byteLength < 0) {
|
||||
throw AxiomByteCodecException("String byte length cannot be negative: $byteLength")
|
||||
}
|
||||
if (byteLength > maxLength * 4) {
|
||||
throw AxiomByteCodecException("Encoded string is too long: $byteLength bytes")
|
||||
}
|
||||
|
||||
val value = String(readBytes(byteLength), StandardCharsets.UTF_8)
|
||||
if (value.length > maxLength) {
|
||||
throw AxiomByteCodecException("Decoded string is too long: ${value.length} characters")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun readBytes(length: Int): ByteArray {
|
||||
if (length < 0) {
|
||||
throw AxiomByteCodecException("Byte length cannot be negative: $length")
|
||||
}
|
||||
|
||||
requireReadable(length)
|
||||
val value = bytes.copyOfRange(readerIndex, readerIndex + length)
|
||||
readerIndex += length
|
||||
return value
|
||||
}
|
||||
|
||||
fun skipBytes(length: Int) {
|
||||
if (length < 0) {
|
||||
throw AxiomByteCodecException("Skip length cannot be negative: $length")
|
||||
}
|
||||
|
||||
requireReadable(length)
|
||||
readerIndex += length
|
||||
}
|
||||
|
||||
fun copyRemaining(): ByteArray = readBytes(readableBytes)
|
||||
|
||||
private fun requireReadable(length: Int) {
|
||||
if (readableBytes < length) {
|
||||
throw AxiomByteCodecException("Need $length readable bytes, only $readableBytes available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val BIG_ENDIAN_LONGS: VarHandle =
|
||||
MethodHandles.byteArrayViewVarHandle(LongArray::class.java, ByteOrder.BIG_ENDIAN)
|
||||
|
||||
class AxiomByteCodecException(message: String) : IllegalArgumentException(message)
|
||||
@@ -0,0 +1,98 @@
|
||||
package me.wargearsystems.viaaxiom.binary
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class AxiomByteWriter(initialCapacity: Int = 256) {
|
||||
private var buffer = ByteArray(initialCapacity.coerceAtLeast(16))
|
||||
|
||||
var size: Int = 0
|
||||
private set
|
||||
|
||||
fun writeByte(value: Int): AxiomByteWriter {
|
||||
ensureCapacity(1)
|
||||
buffer[size++] = value.toByte()
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeBoolean(value: Boolean): AxiomByteWriter = writeByte(if (value) 1 else 0)
|
||||
|
||||
fun writeShort(value: Int): AxiomByteWriter {
|
||||
writeByte(value ushr 8)
|
||||
writeByte(value)
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeInt(value: Int): AxiomByteWriter {
|
||||
writeByte(value ushr 24)
|
||||
writeByte(value ushr 16)
|
||||
writeByte(value ushr 8)
|
||||
writeByte(value)
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeLong(value: Long): AxiomByteWriter {
|
||||
for (shift in 56 downTo 0 step 8) {
|
||||
writeByte((value ushr shift).toInt())
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeLongArray(values: LongArray, includeLength: Boolean = true): AxiomByteWriter {
|
||||
if (includeLength) {
|
||||
writeVarInt(values.size)
|
||||
}
|
||||
ensureCapacity(values.size * Long.SIZE_BYTES)
|
||||
for (value in values) {
|
||||
BIG_ENDIAN_LONGS.set(buffer, size, value)
|
||||
size += Long.SIZE_BYTES
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeVarInt(value: Int): AxiomByteWriter {
|
||||
var remaining = value
|
||||
while ((remaining and -0x80) != 0) {
|
||||
writeByte((remaining and 0x7F) or 0x80)
|
||||
remaining = remaining ushr 7
|
||||
}
|
||||
writeByte(remaining)
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeUtf(value: String, maxLength: Int = 32767): AxiomByteWriter {
|
||||
if (value.length > maxLength) {
|
||||
throw AxiomByteCodecException("String is too long: ${value.length} characters")
|
||||
}
|
||||
|
||||
val encoded = value.toByteArray(StandardCharsets.UTF_8)
|
||||
if (encoded.size > maxLength * 4) {
|
||||
throw AxiomByteCodecException("Encoded string is too long: ${encoded.size} bytes")
|
||||
}
|
||||
|
||||
writeVarInt(encoded.size)
|
||||
writeBytes(encoded)
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeByteArray(bytes: ByteArray): AxiomByteWriter {
|
||||
writeVarInt(bytes.size)
|
||||
writeBytes(bytes)
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeBytes(bytes: ByteArray): AxiomByteWriter {
|
||||
ensureCapacity(bytes.size)
|
||||
bytes.copyInto(buffer, size)
|
||||
size += bytes.size
|
||||
return this
|
||||
}
|
||||
|
||||
fun toByteArray(): ByteArray = buffer.copyOf(size)
|
||||
|
||||
private fun ensureCapacity(additional: Int) {
|
||||
val required = size + additional
|
||||
if (required > buffer.size) {
|
||||
buffer = buffer.copyOf(maxOf(required, buffer.size * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomCustomBlockCodec {
|
||||
fun rewrite(bytes: ByteArray, mapper: (Int) -> Int): ByteArray {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val writer = AxiomByteWriter()
|
||||
writer.writeUtf(reader.readUtf()) // identifier
|
||||
writer.writeUtf(reader.readUtf()) // translation key
|
||||
val properties = count(reader, writer)
|
||||
repeat(properties) {
|
||||
val type = reader.readUnsignedByte()
|
||||
writer.writeByte(type)
|
||||
when (type) {
|
||||
0 -> writer.writeUtf(reader.readUtf())
|
||||
1 -> writer.writeUtf(reader.readUtf()).writeInt(reader.readInt()).writeInt(reader.readInt())
|
||||
2 -> {
|
||||
writer.writeUtf(reader.readUtf())
|
||||
repeat(count(reader, writer)) { writer.writeUtf(reader.readUtf()) }
|
||||
}
|
||||
in 3..15 -> Unit
|
||||
else -> error("Unsupported Axiom custom block property $type")
|
||||
}
|
||||
}
|
||||
repeat(count(reader, writer)) { state(reader, writer, mapper) }
|
||||
// Item components differ per version and have no codec here yet.
|
||||
require(!reader.readBoolean()) { "Cross-version Axiom custom block item icons are not supported yet" }
|
||||
writer.writeBoolean(false)
|
||||
repeat(3) { writer.writeBoolean(reader.readBoolean()) }
|
||||
repeat(count(reader, writer)) { writer.writeUtf(reader.readUtf()) }
|
||||
writer.writeBoolean(reader.readBoolean())
|
||||
repeat(4) {
|
||||
repeat(count(reader, writer)) { state(reader, writer, mapper); state(reader, writer, mapper) }
|
||||
}
|
||||
require(count(reader, writer) == 0 && reader.readableBytes == 0) { "Unknown Axiom custom block extension" }
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private fun count(reader: AxiomByteReader, writer: AxiomByteWriter): Int {
|
||||
val count = reader.readVarInt()
|
||||
require(count in 0..65_536) { "Invalid custom block collection count" }
|
||||
writer.writeVarInt(count)
|
||||
return count
|
||||
}
|
||||
|
||||
private fun state(reader: AxiomByteReader, writer: AxiomByteWriter, mapper: (Int) -> Int) {
|
||||
val id = reader.readVarInt()
|
||||
if (id == -1) writer.writeVarInt(-1).writeUtf(reader.readUtf())
|
||||
else {
|
||||
require(id >= 0) { "Invalid custom block state ID" }
|
||||
writer.writeVarInt(mapper(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
data class AxiomCustomPayloadFrame(
|
||||
val packetId: Int,
|
||||
val channel: String,
|
||||
val payloadBytes: ByteArray,
|
||||
)
|
||||
|
||||
object AxiomCustomPayloadFrameCodec {
|
||||
fun read(frameBytes: ByteArray): AxiomCustomPayloadFrame {
|
||||
val reader = AxiomByteReader(frameBytes)
|
||||
return AxiomCustomPayloadFrame(
|
||||
packetId = reader.readVarInt(),
|
||||
channel = reader.readUtf(),
|
||||
payloadBytes = reader.copyRemaining(),
|
||||
)
|
||||
}
|
||||
|
||||
fun readIfCustomPayload(frameBytes: ByteArray, customPayloadPacketId: Int): AxiomCustomPayloadFrame? {
|
||||
val reader = AxiomByteReader(frameBytes)
|
||||
val packetId = reader.readVarInt()
|
||||
if (packetId != customPayloadPacketId) return null
|
||||
return AxiomCustomPayloadFrame(packetId, reader.readUtf(), reader.copyRemaining())
|
||||
}
|
||||
|
||||
fun write(frame: AxiomCustomPayloadFrame): ByteArray {
|
||||
return AxiomByteWriter()
|
||||
.writeVarInt(frame.packetId)
|
||||
.writeUtf(frame.channel)
|
||||
.writeBytes(frame.payloadBytes)
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
fun rewriteIfCustomPayload(
|
||||
frameBytes: ByteArray,
|
||||
customPayloadPacketId: Int,
|
||||
rewriter: (AxiomCustomPayloadFrame) -> ByteArray,
|
||||
): ByteArray {
|
||||
val frame = readIfCustomPayload(frameBytes, customPayloadPacketId) ?: return frameBytes
|
||||
return write(frame.copy(payloadBytes = rewriter(frame)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomEnableCodec {
|
||||
fun read(bytes: ByteArray, apiVersion: Int = AxiomProtocol.LEGACY_API_VERSION): AxiomEnable {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
return read(reader, apiVersion)
|
||||
}
|
||||
|
||||
fun read(reader: AxiomByteReader, apiVersion: Int = AxiomProtocol.LEGACY_API_VERSION): AxiomEnable {
|
||||
require(apiVersion == AxiomProtocol.LEGACY_API_VERSION || apiVersion == AxiomProtocol.API_VERSION) {
|
||||
"Unsupported Axiom API version: $apiVersion"
|
||||
}
|
||||
val enabled = reader.readBoolean()
|
||||
if (!enabled) return AxiomEnable(false, 0, 0, 0, 0)
|
||||
if (apiVersion == AxiomProtocol.LEGACY_API_VERSION) {
|
||||
return AxiomEnable(true, reader.readInt(), reader.readVarInt(), reader.readVarInt(), reader.readVarInt())
|
||||
}
|
||||
val configVersion = reader.readUnsignedByte()
|
||||
require(configVersion == 0) { "Unsupported Axiom server config version: $configVersion" }
|
||||
val blueprintVersion = reader.readVarInt()
|
||||
val splitSize = reader.readInt()
|
||||
val maximumSize = reader.readInt()
|
||||
require(splitSize > 1 && maximumSize > 0) { "Invalid Axiom tunnel limits" }
|
||||
// Upstream always sends these empty, and their format is unknown.
|
||||
val customDataCount = reader.readVarInt()
|
||||
require(customDataCount == 0) { "Axiom custom data overrides are not supported" }
|
||||
val rotationCount = reader.readVarInt()
|
||||
require(rotationCount == 0) { "Axiom rotation overrides are not supported" }
|
||||
val channelCount = reader.readVarInt()
|
||||
require(channelCount in 0..1024) { "Invalid Axiom channel count: $channelCount" }
|
||||
val channels = List(channelCount) { reader.readUtf() }
|
||||
require(reader.readableBytes == 0) { "Trailing bytes in API 10 enable" }
|
||||
return AxiomEnable(
|
||||
enabled = true, blueprintVersion = blueprintVersion,
|
||||
customDataOverridesCount = customDataCount, rotationOverridesCount = rotationCount,
|
||||
serverConfigVersion = configVersion, tunnelSplitSize = splitSize,
|
||||
maximumTunnelPacketSize = maximumSize, supportedServerboundChannels = channels,
|
||||
)
|
||||
}
|
||||
|
||||
fun write(enable: AxiomEnable, apiVersion: Int = AxiomProtocol.LEGACY_API_VERSION): ByteArray {
|
||||
require(apiVersion == AxiomProtocol.LEGACY_API_VERSION || apiVersion == AxiomProtocol.API_VERSION)
|
||||
val writer = AxiomByteWriter().writeBoolean(enable.enabled)
|
||||
if (!enable.enabled) return writer.toByteArray()
|
||||
if (apiVersion == AxiomProtocol.LEGACY_API_VERSION) {
|
||||
return writer.writeInt(enable.maxBufferSize).writeVarInt(enable.blueprintVersion)
|
||||
.writeVarInt(enable.customDataOverridesCount).writeVarInt(enable.rotationOverridesCount).toByteArray()
|
||||
}
|
||||
require(enable.serverConfigVersion == 0)
|
||||
require(enable.customDataOverridesCount == 0 && enable.rotationOverridesCount == 0)
|
||||
val splitSize = requireNotNull(enable.tunnelSplitSize)
|
||||
val maximumSize = requireNotNull(enable.maximumTunnelPacketSize)
|
||||
require(splitSize > 1 && maximumSize > 0)
|
||||
require(enable.supportedServerboundChannels.size <= 1024)
|
||||
writer.writeByte(0).writeVarInt(enable.blueprintVersion).writeInt(splitSize).writeInt(maximumSize)
|
||||
.writeVarInt(0).writeVarInt(0).writeVarInt(enable.supportedServerboundChannels.size)
|
||||
enable.supportedServerboundChannels.forEach { writer.writeUtf(it) }
|
||||
return writer.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
data class AxiomHello(
|
||||
val apiVersion: Int,
|
||||
val dataVersion: Int,
|
||||
val protocolVersion: Int,
|
||||
val handshakeId: Long? = null,
|
||||
) {
|
||||
val isSupportedApi: Boolean
|
||||
get() = apiVersion == AxiomProtocol.LEGACY_API_VERSION || apiVersion == AxiomProtocol.API_VERSION
|
||||
}
|
||||
|
||||
data class AxiomEnable(
|
||||
val enabled: Boolean,
|
||||
val maxBufferSize: Int = 0,
|
||||
val blueprintVersion: Int,
|
||||
val customDataOverridesCount: Int,
|
||||
val rotationOverridesCount: Int,
|
||||
val serverConfigVersion: Int? = null,
|
||||
val tunnelSplitSize: Int? = null,
|
||||
val maximumTunnelPacketSize: Int? = null,
|
||||
val supportedServerboundChannels: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class AxiomTranslationSession(
|
||||
val hello: AxiomHello,
|
||||
val serverProtocolVersion: Int,
|
||||
val serverDataVersion: Int? = null,
|
||||
val enable: AxiomEnable? = null,
|
||||
) {
|
||||
val protocolMismatch: Boolean
|
||||
get() = hello.protocolVersion != serverProtocolVersion
|
||||
|
||||
val dataVersionMismatch: Boolean
|
||||
get() = serverDataVersion != null && hello.dataVersion != serverDataVersion
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
|
||||
class AxiomHandshakeTracker {
|
||||
var hello: AxiomHello? = null
|
||||
private set
|
||||
var enable: AxiomEnable? = null
|
||||
private set
|
||||
var challengeId: Long? = null
|
||||
private set
|
||||
var goodbyeReason: String? = null
|
||||
private set
|
||||
|
||||
fun observe(direction: AxiomPayloadDirection, channel: String, bytes: ByteArray) {
|
||||
when (AxiomPayloadKey(direction, channel)) {
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("hello")) -> {
|
||||
reset()
|
||||
require(bytes.size == Long.SIZE_BYTES) { "Invalid Axiom handshake challenge" }
|
||||
challengeId = AxiomByteReader(bytes).readLong()
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.SERVERBOUND, AxiomProtocol.channel("hello")) -> {
|
||||
hello = null
|
||||
enable = null
|
||||
val candidate = AxiomHelloCodec.read(bytes)
|
||||
if (candidate.apiVersion == AxiomProtocol.API_VERSION) {
|
||||
require(challengeId != null && candidate.handshakeId == challengeId) {
|
||||
"Axiom hello does not match the server handshake challenge"
|
||||
}
|
||||
} else {
|
||||
require(challengeId == null) { "API 9 hello cannot answer an API 10 challenge" }
|
||||
}
|
||||
hello = candidate
|
||||
goodbyeReason = null
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("enable")) -> {
|
||||
enable = null
|
||||
val apiVersion = requireNotNull(hello) { "Axiom enable received before client hello" }.apiVersion
|
||||
enable = AxiomEnableCodec.read(bytes, apiVersion)
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("redo_handshake")) -> reset()
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("goodbye")) -> {
|
||||
reset()
|
||||
goodbyeReason = AxiomByteReader(bytes).readUtf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun session(serverProtocolVersion: Int?, serverDataVersion: Int? = null): AxiomTranslationSession? {
|
||||
val hello = hello ?: return null
|
||||
val enable = enable ?: return null
|
||||
if (!enable.enabled || serverProtocolVersion == null) return null
|
||||
return AxiomTranslationSession(hello, serverProtocolVersion, serverDataVersion, enable)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
hello = null
|
||||
enable = null
|
||||
challengeId = null
|
||||
goodbyeReason = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomHelloCodec {
|
||||
fun read(bytes: ByteArray): AxiomHello {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val hello = read(reader)
|
||||
require(reader.readableBytes == 0) { "Trailing bytes in Axiom hello" }
|
||||
return hello
|
||||
}
|
||||
|
||||
fun read(reader: AxiomByteReader): AxiomHello {
|
||||
val apiVersion = reader.readVarInt()
|
||||
require(apiVersion == AxiomProtocol.LEGACY_API_VERSION || apiVersion == AxiomProtocol.API_VERSION) {
|
||||
"Unsupported Axiom API version: $apiVersion"
|
||||
}
|
||||
val dataVersion = reader.readVarInt()
|
||||
val protocolVersion = reader.readVarInt()
|
||||
val handshakeId = if (apiVersion == AxiomProtocol.API_VERSION) reader.readLong() else null
|
||||
return AxiomHello(apiVersion, dataVersion, protocolVersion, handshakeId)
|
||||
}
|
||||
|
||||
fun write(hello: AxiomHello): ByteArray {
|
||||
require(hello.isSupportedApi) { "Unsupported Axiom API version: ${hello.apiVersion}" }
|
||||
val writer = AxiomByteWriter()
|
||||
.writeVarInt(hello.apiVersion)
|
||||
.writeVarInt(hello.dataVersion)
|
||||
.writeVarInt(hello.protocolVersion)
|
||||
if (hello.apiVersion == AxiomProtocol.API_VERSION) {
|
||||
writer.writeLong(requireNotNull(hello.handshakeId) { "API 10 hello requires a handshake ID" })
|
||||
} else {
|
||||
require(hello.handshakeId == null) { "API 9 hello cannot contain a handshake ID" }
|
||||
}
|
||||
return writer.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomPayload
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomPayloadTranslator
|
||||
import java.util.UUID
|
||||
|
||||
/** API 10 client <-> API 9 server. The server still decides permissions and activation. */
|
||||
class AxiomLegacyBridge(
|
||||
private val serverProtocolVersion: Int,
|
||||
private val translator: AxiomPayloadTranslator,
|
||||
private val challengeSource: () -> Long = { UUID.randomUUID().mostSignificantBits },
|
||||
) {
|
||||
private val channels = linkedSetOf<String>()
|
||||
private var modernServer = false
|
||||
private var challenge: Long? = null
|
||||
private var hello: AxiomHello? = null
|
||||
private var enable: AxiomEnable? = null
|
||||
private val tunnel = AxiomTunnelCollector()
|
||||
|
||||
val awaitingLegacyChallenge: Boolean
|
||||
get() = !modernServer && challenge == null && "axiom:hello" in channels && "axiom:tunnel" !in channels
|
||||
|
||||
val active: Boolean get() = !modernServer && challenge != null
|
||||
|
||||
fun register(bytes: ByteArray) {
|
||||
require(bytes.size <= 131_072) { "Oversized channel registration" }
|
||||
channels.addAll(bytes.toString(Charsets.UTF_8).split('\u0000').filter(AxiomProtocol::isAxiomChannel))
|
||||
if ("axiom:tunnel" in channels) {
|
||||
modernServer = true
|
||||
clearNegotiation()
|
||||
}
|
||||
}
|
||||
|
||||
fun createChallenge(packetId: Int): AxiomCustomPayloadFrame? {
|
||||
if (!awaitingLegacyChallenge) return null
|
||||
val id = challengeSource()
|
||||
challenge = id
|
||||
return AxiomCustomPayloadFrame(packetId, "axiom:hello", AxiomByteWriter().writeLong(id).toByteArray())
|
||||
}
|
||||
|
||||
fun clientbound(frame: AxiomCustomPayloadFrame): AxiomCustomPayloadFrame {
|
||||
if (frame.channel == "axiom:hello" || frame.channel == "axiom:goodbye") {
|
||||
modernServer = true
|
||||
clearNegotiation()
|
||||
return frame
|
||||
}
|
||||
if (!active) return frame
|
||||
if (frame.channel == "axiom:redo_handshake") {
|
||||
clearNegotiation()
|
||||
val id = challengeSource()
|
||||
challenge = id
|
||||
return frame.copy(channel = "axiom:hello", payloadBytes = AxiomByteWriter().writeLong(id).toByteArray())
|
||||
}
|
||||
if (frame.channel == "axiom:enable") {
|
||||
val reader = AxiomByteReader(frame.payloadBytes)
|
||||
val legacy = AxiomEnableCodec.read(reader, 9)
|
||||
require(reader.readableBytes == 0 && legacy.customDataOverridesCount == 0 && legacy.rotationOverridesCount == 0) {
|
||||
"Unsupported legacy Axiom configuration"
|
||||
}
|
||||
require(!legacy.enabled || hello != null) { "Legacy enable before client hello" }
|
||||
require(!legacy.enabled || legacy.maxBufferSize in 1..AxiomTunnelCodec.DEFAULT_MAXIMUM_PACKET_SIZE) {
|
||||
"Unsupported legacy Axiom buffer limit"
|
||||
}
|
||||
enable = legacy
|
||||
tunnel.clear()
|
||||
val supported = channels.filter { it in AxiomProtocol.SERVERBOUND_CHANNELS && it != "axiom:tunnel" }
|
||||
val modern = legacy.copy(
|
||||
serverConfigVersion = 0,
|
||||
tunnelSplitSize = AxiomTunnelCodec.DEFAULT_SPLIT_SIZE,
|
||||
maximumTunnelPacketSize = legacy.maxBufferSize,
|
||||
// The bridge terminates the tunnel, the API 9 server never sees it.
|
||||
supportedServerboundChannels = supported + "axiom:tunnel",
|
||||
)
|
||||
return frame.copy(payloadBytes = AxiomEnableCodec.write(modern, 10))
|
||||
}
|
||||
return rewrite(frame, AxiomPayloadDirection.CLIENTBOUND)
|
||||
}
|
||||
|
||||
/** Null consumes an intermediate tunnel fragment. */
|
||||
fun serverbound(frame: AxiomCustomPayloadFrame): AxiomCustomPayloadFrame? {
|
||||
if (!active) return frame
|
||||
if (frame.channel == "axiom:hello") {
|
||||
val candidate = AxiomHelloCodec.read(frame.payloadBytes)
|
||||
require(candidate.apiVersion == 10 && candidate.handshakeId == challenge) { "Unexpected legacy bridge challenge reply" }
|
||||
hello = candidate
|
||||
enable = null
|
||||
tunnel.clear()
|
||||
// The bridge translates IDs, so the server must not.
|
||||
return frame.copy(payloadBytes = AxiomHelloCodec.write(candidate.copy(apiVersion = 9,
|
||||
protocolVersion = serverProtocolVersion, handshakeId = null)))
|
||||
}
|
||||
if (frame.channel != "axiom:tunnel") return rewrite(frame, AxiomPayloadDirection.SERVERBOUND)
|
||||
val negotiated = requireNotNull(enable?.takeIf { it.enabled }) { "Tunnel sent before legacy server enabled Axiom" }
|
||||
val fragments = tunnel.add(frame.payloadBytes, AxiomTunnelCodec.DEFAULT_SPLIT_SIZE, negotiated.maxBufferSize) ?: return null
|
||||
val packet = AxiomTunnelCodec.decode(fragments, maximumPacketSize = negotiated.maxBufferSize)
|
||||
require(packet.channel in channels && packet.channel in AxiomProtocol.SERVERBOUND_CHANNELS && packet.channel != "axiom:hello") {
|
||||
"Legacy server did not register ${packet.channel}"
|
||||
}
|
||||
val output = rewrite(frame.copy(channel = packet.channel, payloadBytes = packet.payload), AxiomPayloadDirection.SERVERBOUND)
|
||||
val largeChannels = setOf("axiom:set_buffer", "axiom:upload_blueprint", "axiom:annotation_update")
|
||||
val maximum = if (output.channel in largeChannels) negotiated.maxBufferSize else 32_767
|
||||
require(output.payloadBytes.size <= maximum) { "${output.channel} exceeds legacy transport limit ($maximum)" }
|
||||
return output
|
||||
}
|
||||
|
||||
private fun rewrite(frame: AxiomCustomPayloadFrame, direction: AxiomPayloadDirection): AxiomCustomPayloadFrame {
|
||||
if (enable?.enabled != true) return frame
|
||||
val session = AxiomTranslationSession(requireNotNull(hello), serverProtocolVersion, enable = enable)
|
||||
return frame.copy(payloadBytes = translator.rewrite(AxiomPayload(direction, frame.channel, frame.payloadBytes), session))
|
||||
}
|
||||
|
||||
private fun clearNegotiation() {
|
||||
challenge = null
|
||||
hello = null
|
||||
enable = null
|
||||
tunnel.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomPayload
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomPayloadTranslator
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomTunnelTranslationEngine
|
||||
|
||||
/** API 10 client <-> API 10 server on another Minecraft version. */
|
||||
class AxiomModernBridge(
|
||||
private val serverProtocolVersion: Int,
|
||||
private val translator: AxiomPayloadTranslator,
|
||||
) {
|
||||
private val handshake = AxiomHandshakeTracker()
|
||||
private val tunnelEngine = AxiomTunnelTranslationEngine(translator)
|
||||
private val tunnel = AxiomTunnelCollector()
|
||||
|
||||
fun clientbound(frame: AxiomCustomPayloadFrame): AxiomCustomPayloadFrame {
|
||||
if (frame.channel in HANDSHAKE_CHANNELS) {
|
||||
tunnel.clear()
|
||||
handshake.observe(AxiomPayloadDirection.CLIENTBOUND, frame.channel, frame.payloadBytes)
|
||||
return frame
|
||||
}
|
||||
val session = session ?: return frame
|
||||
return frame.copy(payloadBytes = translator.rewrite(
|
||||
AxiomPayload(AxiomPayloadDirection.CLIENTBOUND, frame.channel, frame.payloadBytes), session))
|
||||
}
|
||||
|
||||
/** Empty consumes an intermediate tunnel fragment. Translation can change the fragment count. */
|
||||
fun serverbound(frame: AxiomCustomPayloadFrame): List<AxiomCustomPayloadFrame> {
|
||||
if (frame.channel == "axiom:hello") {
|
||||
if (handshake.challengeId == null) return listOf(frame)
|
||||
tunnel.clear()
|
||||
handshake.observe(AxiomPayloadDirection.SERVERBOUND, frame.channel, frame.payloadBytes)
|
||||
// The bridge translates IDs, so the server must not.
|
||||
val hello = requireNotNull(handshake.hello)
|
||||
return listOf(frame.copy(payloadBytes = AxiomHelloCodec.write(hello.copy(protocolVersion = serverProtocolVersion))))
|
||||
}
|
||||
val session = session ?: return listOf(frame)
|
||||
if (frame.channel != "axiom:tunnel") {
|
||||
return listOf(frame.copy(payloadBytes = translator.rewrite(
|
||||
AxiomPayload(AxiomPayloadDirection.SERVERBOUND, frame.channel, frame.payloadBytes), session)))
|
||||
}
|
||||
val enable = requireNotNull(session.enable)
|
||||
val fragments = tunnel.add(frame.payloadBytes, requireNotNull(enable.tunnelSplitSize),
|
||||
requireNotNull(enable.maximumTunnelPacketSize)) ?: return emptyList()
|
||||
return tunnelEngine.rewrite(fragments, session).map { frame.copy(payloadBytes = it) }
|
||||
}
|
||||
|
||||
private val session: AxiomTranslationSession?
|
||||
get() = handshake.session(serverProtocolVersion)
|
||||
|
||||
private companion object {
|
||||
val HANDSHAKE_CHANNELS = setOf("axiom:hello", "axiom:enable", "axiom:goodbye", "axiom:redo_handshake")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
object AxiomProtocol {
|
||||
const val NAMESPACE: String = "axiom"
|
||||
const val API_VERSION: Int = 10
|
||||
const val LEGACY_API_VERSION: Int = 9
|
||||
const val MIN_POSITION_LONG: Long = -9223371899415820288L
|
||||
|
||||
val CLIENTBOUND_CHANNELS: Set<String> = setOf(
|
||||
channel("enable"),
|
||||
channel("hello"),
|
||||
channel("goodbye"),
|
||||
channel("redo_handshake"),
|
||||
channel("response_chunk_data"),
|
||||
channel("response_entity_data"),
|
||||
channel("register_world_properties"),
|
||||
channel("set_world_property"),
|
||||
channel("ack_world_properties"),
|
||||
channel("restrictions"),
|
||||
channel("marker_data"),
|
||||
channel("marker_nbt_response"),
|
||||
channel("annotation_update"),
|
||||
channel("add_server_heightmap"),
|
||||
channel("custom_blocks"),
|
||||
channel("register_custom_block_v2"),
|
||||
channel("ignore_display_entities"),
|
||||
channel("register_custom_items"),
|
||||
channel("blueprint_manifest"),
|
||||
channel("response_blueprint"),
|
||||
)
|
||||
|
||||
val SERVERBOUND_CHANNELS: Set<String> = setOf(
|
||||
channel("hello"),
|
||||
channel("tunnel"),
|
||||
channel("tick_blocks"),
|
||||
channel("set_gamemode"),
|
||||
channel("set_fly_speed"),
|
||||
channel("teleport"),
|
||||
channel("set_world_time"),
|
||||
channel("set_no_physical_trigger"),
|
||||
channel("set_world_property"),
|
||||
channel("request_chunk_data"),
|
||||
channel("request_entity_data"),
|
||||
channel("spawn_entity"),
|
||||
channel("manipulate_entity"),
|
||||
channel("delete_entity"),
|
||||
channel("marker_nbt_request"),
|
||||
channel("set_block"),
|
||||
channel("set_buffer"),
|
||||
channel("upload_blueprint"),
|
||||
channel("request_blueprint"),
|
||||
channel("annotation_update"),
|
||||
)
|
||||
|
||||
val BLOCK_STATE_TRANSLATION_CHANNELS: Set<AxiomPayloadKey> = setOf(
|
||||
AxiomPayloadKey(AxiomPayloadDirection.SERVERBOUND, channel("set_block")),
|
||||
AxiomPayloadKey(AxiomPayloadDirection.SERVERBOUND, channel("set_buffer")),
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, channel("response_chunk_data")),
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, channel("register_custom_block_v2")),
|
||||
)
|
||||
|
||||
fun channel(path: String): String = "$NAMESPACE:$path"
|
||||
|
||||
fun isAxiomChannel(channel: String): Boolean = channel.startsWith("$NAMESPACE:")
|
||||
}
|
||||
|
||||
enum class AxiomPayloadDirection {
|
||||
CLIENTBOUND,
|
||||
SERVERBOUND,
|
||||
}
|
||||
|
||||
data class AxiomPayloadKey(
|
||||
val direction: AxiomPayloadDirection,
|
||||
val channel: String,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.translation.AxiomPayloadTranslator
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One player's Axiom traffic to one backend.
|
||||
*
|
||||
* An exception means the frame could not be translated. Never forward such a frame,
|
||||
* its IDs would select the wrong blocks.
|
||||
*/
|
||||
class AxiomProxySession(
|
||||
private val clientProtocolVersion: Int,
|
||||
private val serverProtocolVersion: Int,
|
||||
translator: AxiomPayloadTranslator,
|
||||
challengeSource: () -> Long = { UUID.randomUUID().mostSignificantBits },
|
||||
) {
|
||||
private val legacy = AxiomLegacyBridge(serverProtocolVersion, translator, challengeSource)
|
||||
private val modern = AxiomModernBridge(serverProtocolVersion, translator)
|
||||
|
||||
/** Set for clients other than API 10, which talk to the backend directly. */
|
||||
var passthrough = false
|
||||
private set
|
||||
|
||||
val awaitingLegacyChallenge: Boolean
|
||||
get() = !passthrough && legacy.awaitingLegacyChallenge
|
||||
|
||||
fun backendRegistered(bytes: ByteArray) = legacy.register(bytes)
|
||||
|
||||
fun startLegacyHandshake(): AxiomCustomPayloadFrame? = if (passthrough) null else legacy.createChallenge(0)
|
||||
|
||||
fun clientbound(frame: AxiomCustomPayloadFrame): AxiomCustomPayloadFrame {
|
||||
if (passthrough) return frame
|
||||
val output = legacy.clientbound(frame)
|
||||
if (legacy.active || !versionsDiffer) return output
|
||||
return modern.clientbound(output)
|
||||
}
|
||||
|
||||
/** Empty consumes an intermediate tunnel fragment. */
|
||||
fun serverbound(frame: AxiomCustomPayloadFrame): List<AxiomCustomPayloadFrame> {
|
||||
if (passthrough) return listOf(frame)
|
||||
if (frame.channel == "axiom:hello" && AxiomByteReader(frame.payloadBytes).readVarInt() != AxiomProtocol.API_VERSION) {
|
||||
passthrough = true
|
||||
return listOf(frame)
|
||||
}
|
||||
if (legacy.active) return listOfNotNull(legacy.serverbound(frame))
|
||||
if (!versionsDiffer) return listOf(frame)
|
||||
return modern.serverbound(frame)
|
||||
}
|
||||
|
||||
private val versionsDiffer: Boolean
|
||||
get() = clientProtocolVersion != serverProtocolVersion
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomResponseChunkDataPayloadCodec {
|
||||
fun rewriteSections(
|
||||
bytes: ByteArray,
|
||||
sectionRewriter: (reader: AxiomByteReader, writer: AxiomByteWriter) -> Unit,
|
||||
): ByteArray {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val writer = AxiomByteWriter(bytes.size)
|
||||
|
||||
writer.writeLong(reader.readLong()) // Request id.
|
||||
copyBlockEntities(reader, writer)
|
||||
copySections(reader, writer, sectionRewriter)
|
||||
writer.writeBoolean(reader.readBoolean()) // Finished.
|
||||
writer.writeBytes(reader.copyRemaining())
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private fun copyBlockEntities(reader: AxiomByteReader, writer: AxiomByteWriter) {
|
||||
while (true) {
|
||||
val blockEntityPos = reader.readLong()
|
||||
writer.writeLong(blockEntityPos)
|
||||
if (blockEntityPos == AxiomProtocol.MIN_POSITION_LONG) {
|
||||
break
|
||||
}
|
||||
|
||||
writer.writeVarInt(reader.readVarInt()) // Original NBT size.
|
||||
writer.writeByte(reader.readUnsignedByte()) // Compression dictionary id.
|
||||
writer.writeByteArray(reader.readByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun copySections(
|
||||
reader: AxiomByteReader,
|
||||
writer: AxiomByteWriter,
|
||||
sectionRewriter: (reader: AxiomByteReader, writer: AxiomByteWriter) -> Unit,
|
||||
) {
|
||||
while (true) {
|
||||
val sectionIndex = reader.readLong()
|
||||
writer.writeLong(sectionIndex)
|
||||
if (sectionIndex == AxiomProtocol.MIN_POSITION_LONG) {
|
||||
break
|
||||
}
|
||||
|
||||
val hasContainer = reader.readBoolean()
|
||||
writer.writeBoolean(hasContainer)
|
||||
if (hasContainer) {
|
||||
sectionRewriter(reader, writer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomSetBlockPayloadCodec {
|
||||
fun rewriteBlockStateIds(bytes: ByteArray, mapper: (Int) -> Int): ByteArray {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val writer = AxiomByteWriter()
|
||||
|
||||
val blockCount = reader.readVarInt()
|
||||
writer.writeVarInt(blockCount)
|
||||
|
||||
repeat(blockCount) {
|
||||
writer.writeLong(reader.readLong())
|
||||
writer.writeVarInt(mapper(reader.readVarInt()))
|
||||
}
|
||||
|
||||
writer.writeBytes(reader.copyRemaining())
|
||||
return writer.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
|
||||
object AxiomSetBufferPayloadCodec {
|
||||
private const val BLOCK_BUFFER_TYPE: Int = 0
|
||||
|
||||
fun rewriteBlockSections(
|
||||
bytes: ByteArray,
|
||||
sectionRewriter: (reader: AxiomByteReader, writer: AxiomByteWriter) -> Unit,
|
||||
): ByteArray {
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val writer = AxiomByteWriter(bytes.size)
|
||||
|
||||
writer.writeUtf(reader.readUtf()) // Dimension resource key value.
|
||||
writer.writeLong(reader.readLong()) // UUID most significant bits.
|
||||
writer.writeLong(reader.readLong()) // UUID least significant bits.
|
||||
|
||||
val type = reader.readUnsignedByte()
|
||||
writer.writeByte(type)
|
||||
if (type != BLOCK_BUFFER_TYPE) {
|
||||
writer.writeBytes(reader.copyRemaining())
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val sectionIndex = reader.readLong()
|
||||
writer.writeLong(sectionIndex)
|
||||
if (sectionIndex == AxiomProtocol.MIN_POSITION_LONG) {
|
||||
break
|
||||
}
|
||||
|
||||
sectionRewriter(reader, writer)
|
||||
copyBlockEntities(reader, writer)
|
||||
}
|
||||
|
||||
writer.writeBytes(reader.copyRemaining())
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private fun copyBlockEntities(reader: AxiomByteReader, writer: AxiomByteWriter) {
|
||||
val blockEntityCount = reader.readVarInt()
|
||||
writer.writeVarInt(blockEntityCount)
|
||||
|
||||
repeat(blockEntityCount) {
|
||||
writer.writeShort(reader.readShort().toInt())
|
||||
writer.writeVarInt(reader.readVarInt()) // Original NBT size.
|
||||
writer.writeByte(reader.readUnsignedByte()) // Compression dictionary id.
|
||||
writer.writeByteArray(reader.readByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import com.github.luben.zstd.Zstd
|
||||
import com.github.luben.zstd.ZstdException
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
data class AxiomTunnelPacket(val channel: String, val payload: ByteArray, val compressed: Boolean = false)
|
||||
|
||||
/** API 10 serverbound transport. Split size includes the one-byte fragment flags. */
|
||||
object AxiomTunnelCodec {
|
||||
const val FIRST = 1
|
||||
const val LAST = 2
|
||||
const val ZSTD_COMPRESSED = 1
|
||||
const val DEFAULT_SPLIT_SIZE = 31_000
|
||||
const val DEFAULT_MAXIMUM_PACKET_SIZE = 2_097_152
|
||||
private const val MAX_HEADER_SIZE = 131_080
|
||||
|
||||
fun decode(
|
||||
fragments: List<ByteArray>,
|
||||
splitSize: Int = DEFAULT_SPLIT_SIZE,
|
||||
maximumPacketSize: Int = DEFAULT_MAXIMUM_PACKET_SIZE,
|
||||
): AxiomTunnelPacket {
|
||||
require(splitSize > 1 && maximumPacketSize > 0)
|
||||
require(fragments.isNotEmpty()) { "Empty Axiom tunnel packet" }
|
||||
val combined = ByteArrayOutputStream()
|
||||
val maximumEncodedSize = Zstd.compressBound(maximumPacketSize.toLong()) + MAX_HEADER_SIZE
|
||||
fragments.forEachIndexed { index, fragment ->
|
||||
require(fragment.size in 2..splitSize) { "Invalid Axiom tunnel fragment size" }
|
||||
val expectedFlags = (if (index == 0) FIRST else 0) or (if (index == fragments.lastIndex) LAST else 0)
|
||||
require((fragment[0].toInt() and 0xFF) == expectedFlags) { "Invalid Axiom tunnel fragment order or flags" }
|
||||
require(combined.size().toLong() + fragment.size - 1 <= maximumEncodedSize) { "Axiom tunnel packet exceeds limit" }
|
||||
combined.write(fragment, 1, fragment.size - 1)
|
||||
}
|
||||
val reader = AxiomByteReader(combined.toByteArray())
|
||||
val channel = reader.readUtf()
|
||||
require(AxiomProtocol.isAxiomChannel(channel) && channel != AxiomProtocol.channel("tunnel")) {
|
||||
"Invalid tunneled Axiom channel: $channel"
|
||||
}
|
||||
val uncompressedSize = reader.readInt()
|
||||
require(uncompressedSize in 0..maximumPacketSize) { "Axiom tunnel payload exceeds limit" }
|
||||
val flags = reader.readUnsignedByte()
|
||||
require(flags == 0 || flags == ZSTD_COMPRESSED) { "Unsupported Axiom tunnel packet flags: $flags" }
|
||||
val body = reader.copyRemaining()
|
||||
val compressed = flags == ZSTD_COMPRESSED
|
||||
val payload = if (compressed) {
|
||||
val destination = ByteArray(uncompressedSize)
|
||||
val result = try {
|
||||
Zstd.decompressByteArray(destination, 0, destination.size, body, 0, body.size)
|
||||
} catch (exception: ZstdException) {
|
||||
throw IllegalArgumentException("Invalid Zstd Axiom tunnel payload", exception)
|
||||
}
|
||||
require(!Zstd.isError(result)) { "Invalid Zstd Axiom tunnel payload: ${Zstd.getErrorName(result)}" }
|
||||
require(result == uncompressedSize.toLong()) { "Axiom tunnel decompressed size mismatch" }
|
||||
destination
|
||||
} else {
|
||||
require(body.size == uncompressedSize) { "Axiom tunnel payload size mismatch" }
|
||||
body
|
||||
}
|
||||
return AxiomTunnelPacket(channel, payload, compressed)
|
||||
}
|
||||
|
||||
fun encode(
|
||||
packet: AxiomTunnelPacket,
|
||||
splitSize: Int = DEFAULT_SPLIT_SIZE,
|
||||
maximumPacketSize: Int = DEFAULT_MAXIMUM_PACKET_SIZE,
|
||||
): List<ByteArray> {
|
||||
require(splitSize > 1 && maximumPacketSize > 0)
|
||||
require(packet.payload.size <= maximumPacketSize) { "Axiom tunnel payload exceeds limit" }
|
||||
require(AxiomProtocol.isAxiomChannel(packet.channel) && packet.channel != AxiomProtocol.channel("tunnel"))
|
||||
val body = if (packet.compressed) Zstd.compress(packet.payload) else packet.payload
|
||||
val bytes = AxiomByteWriter().writeUtf(packet.channel).writeInt(packet.payload.size)
|
||||
.writeByte(if (packet.compressed) ZSTD_COMPRESSED else 0).writeBytes(body).toByteArray()
|
||||
val bodyPerFragment = splitSize - 1
|
||||
val count = ((bytes.size.toLong() + bodyPerFragment - 1) / bodyPerFragment).toInt()
|
||||
return List(count) { index ->
|
||||
val from = index * bodyPerFragment
|
||||
val length = minOf(bodyPerFragment, bytes.size - from)
|
||||
ByteArray(length + 1).also { fragment ->
|
||||
fragment[0] = ((if (index == 0) FIRST else 0) or (if (index == count - 1) LAST else 0)).toByte()
|
||||
bytes.copyInto(fragment, 1, from, from + length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
class AxiomTunnelCollector {
|
||||
private val fragments = mutableListOf<ByteArray>()
|
||||
private var collectedBytes = 0L
|
||||
|
||||
/** Null until [fragment] completes a packet. */
|
||||
fun add(fragment: ByteArray, splitSize: Int, maximumPacketSize: Int): List<ByteArray>? {
|
||||
try {
|
||||
require(fragment.size in 2..splitSize) { "Invalid tunnel fragment length" }
|
||||
val flags = fragment[0].toInt() and 0xFF
|
||||
require(flags in 0..3) { "Invalid tunnel flags" }
|
||||
if (flags and AxiomTunnelCodec.FIRST != 0) {
|
||||
require(fragments.isEmpty()) { "New tunnel before previous tunnel completed" }
|
||||
} else require(fragments.isNotEmpty()) { "Tunnel continuation without first fragment" }
|
||||
collectedBytes += fragment.size - 1
|
||||
require(collectedBytes <= maximumPacketSize.toLong() + COMPRESSION_MARGIN) { "Tunnel exceeds server limit" }
|
||||
fragments += fragment
|
||||
if (flags and AxiomTunnelCodec.LAST == 0) return null
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
clear()
|
||||
throw failure
|
||||
}
|
||||
return fragments.toList().also { clear() }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
fragments.clear()
|
||||
collectedBytes = 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Incompressible payloads plus the header can slightly exceed the uncompressed limit. */
|
||||
const val COMPRESSION_MARGIN = 262_144L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.math.max
|
||||
|
||||
/** Unboxed, because a section can need 4096 lookups. */
|
||||
fun interface BlockStateIdMapper {
|
||||
fun map(id: Int): Int
|
||||
}
|
||||
|
||||
object PalettedContainerCodec {
|
||||
const val PROTOCOL_1_21_5: Int = 770
|
||||
private const val BLOCK_COUNT: Int = 4096
|
||||
private const val BLOCK_HIGHEST_BITS_PER_VALUE: Int = 8
|
||||
private const val MIN_BLOCK_BITS_PER_VALUE: Int = 4
|
||||
|
||||
fun rewriteBlockStateIds(
|
||||
reader: AxiomByteReader,
|
||||
writer: AxiomByteWriter,
|
||||
protocolVersion: Int,
|
||||
globalPaletteBits: Int,
|
||||
mapper: BlockStateIdMapper,
|
||||
targetProtocolVersion: Int = protocolVersion,
|
||||
targetGlobalPaletteBits: Int = globalPaletteBits,
|
||||
) {
|
||||
val sameLayout = globalPaletteBits == targetGlobalPaletteBits &&
|
||||
(protocolVersion >= PROTOCOL_1_21_5) == (targetProtocolVersion >= PROTOCOL_1_21_5)
|
||||
if (sameLayout) {
|
||||
rewriteInPlace(reader, writer, protocolVersion, globalPaletteBits, mapper)
|
||||
return
|
||||
}
|
||||
val container = readBlockContainer(reader, protocolVersion, globalPaletteBits)
|
||||
container.replaceIds(mapper::map)
|
||||
writeBlockContainer(writer, container, targetProtocolVersion, targetGlobalPaletteBits)
|
||||
}
|
||||
|
||||
/** Palette indexes are copied as they are, and global IDs are mapped inside the packed longs. */
|
||||
private fun rewriteInPlace(
|
||||
reader: AxiomByteReader,
|
||||
writer: AxiomByteWriter,
|
||||
protocolVersion: Int,
|
||||
globalPaletteBits: Int,
|
||||
mapper: BlockStateIdMapper,
|
||||
) {
|
||||
require(protocolVersion >= 757) { "Palette formats before Minecraft 1.18 are not supported" }
|
||||
require(globalPaletteBits in 9..31) { "Invalid global palette width: $globalPaletteBits" }
|
||||
val encodedBits = reader.readUnsignedByte()
|
||||
writer.writeByte(encodedBits)
|
||||
|
||||
if (encodedBits == 0) {
|
||||
writer.writeVarInt(mappedId(mapper, reader.readVarInt(), globalPaletteBits))
|
||||
writeValues(writer, protocolVersion, readValues(reader, protocolVersion, bitsPerValue = 0))
|
||||
return
|
||||
}
|
||||
|
||||
val bitsPerValue = normalizeBitsPerValue(encodedBits, globalPaletteBits)
|
||||
if (bitsPerValue == globalPaletteBits) {
|
||||
val values = readValues(reader, protocolVersion, bitsPerValue)
|
||||
mapGlobalValues(values, bitsPerValue, mapper)
|
||||
writeValues(writer, protocolVersion, values)
|
||||
return
|
||||
}
|
||||
|
||||
val paletteLength = reader.readVarInt()
|
||||
require(paletteLength in 1..(1 shl bitsPerValue)) { "Invalid palette length: $paletteLength" }
|
||||
writer.writeVarInt(paletteLength)
|
||||
repeat(paletteLength) {
|
||||
writer.writeVarInt(mappedId(mapper, reader.readVarInt(), globalPaletteBits))
|
||||
}
|
||||
val values = readValues(reader, protocolVersion, bitsPerValue)
|
||||
requirePaletteIndexes(values, bitsPerValue, paletteLength)
|
||||
writeValues(writer, protocolVersion, values)
|
||||
}
|
||||
|
||||
private fun mappedId(mapper: BlockStateIdMapper, id: Int, globalPaletteBits: Int): Int {
|
||||
val mapped = mapper.map(id)
|
||||
require(mapped >= 0 && mapped.toLong() < (1L shl globalPaletteBits)) {
|
||||
"Block state ID does not fit the target registry width"
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
private fun mapGlobalValues(values: LongArray, bitsPerValue: Int, mapper: BlockStateIdMapper) {
|
||||
val mask = (1L shl bitsPerValue) - 1L
|
||||
val valuesPerLong = 64 / bitsPerValue
|
||||
var remaining = BLOCK_COUNT
|
||||
for (cellIndex in values.indices) {
|
||||
val cell = values[cellIndex]
|
||||
var mappedCell = 0L
|
||||
var position = 0
|
||||
while (position < valuesPerLong && remaining > 0) {
|
||||
val shift = position * bitsPerValue
|
||||
val id = ((cell ushr shift) and mask).toInt()
|
||||
mappedCell = mappedCell or (mappedId(mapper, id, bitsPerValue).toLong() shl shift)
|
||||
position++
|
||||
remaining--
|
||||
}
|
||||
values[cellIndex] = mappedCell
|
||||
}
|
||||
}
|
||||
|
||||
private fun requirePaletteIndexes(values: LongArray, bitsPerValue: Int, paletteLength: Int) {
|
||||
val mask = (1L shl bitsPerValue) - 1L
|
||||
val valuesPerLong = 64 / bitsPerValue
|
||||
var remaining = BLOCK_COUNT
|
||||
for (cell in values) {
|
||||
var current = cell
|
||||
var position = 0
|
||||
while (position < valuesPerLong && remaining > 0) {
|
||||
require((current and mask) < paletteLength) { "Block value references an absent palette entry" }
|
||||
current = current ushr bitsPerValue
|
||||
position++
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readBlockContainer(
|
||||
reader: AxiomByteReader,
|
||||
protocolVersion: Int,
|
||||
globalPaletteBits: Int,
|
||||
): PalettedContainerData {
|
||||
require(protocolVersion >= 757) { "Palette formats before Minecraft 1.18 are not supported" }
|
||||
require(globalPaletteBits in 9..31) { "Invalid global palette width: $globalPaletteBits" }
|
||||
var bitsPerValue = reader.readUnsignedByte()
|
||||
|
||||
if (bitsPerValue == 0) {
|
||||
val palette = mutableListOf(reader.readVarInt())
|
||||
readValues(reader, protocolVersion, bitsPerValue = 0)
|
||||
return PalettedContainerData(palette, IntArray(BLOCK_COUNT))
|
||||
}
|
||||
|
||||
bitsPerValue = normalizeBitsPerValue(bitsPerValue, globalPaletteBits)
|
||||
|
||||
val palette = mutableListOf<Int>()
|
||||
val values: IntArray
|
||||
if (bitsPerValue != globalPaletteBits) {
|
||||
val paletteLength = reader.readVarInt()
|
||||
require(paletteLength in 1..(1 shl bitsPerValue)) { "Invalid palette length: $paletteLength" }
|
||||
repeat(paletteLength) {
|
||||
palette += reader.readVarInt()
|
||||
}
|
||||
values = unpackValues(readValues(reader, protocolVersion, bitsPerValue), bitsPerValue)
|
||||
require(values.all { it in palette.indices }) { "Block value references an absent palette entry" }
|
||||
} else {
|
||||
val rawIds = unpackValues(readValues(reader, protocolVersion, bitsPerValue), bitsPerValue)
|
||||
val indexesById = mutableMapOf<Int, Int>()
|
||||
// Global values are registry IDs, whereas PalettedContainerData stores palette indexes.
|
||||
values = IntArray(BLOCK_COUNT) { index ->
|
||||
indexesById.getOrPut(rawIds[index]) {
|
||||
palette.add(rawIds[index])
|
||||
palette.lastIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PalettedContainerData(palette, values)
|
||||
}
|
||||
|
||||
fun writeBlockContainer(
|
||||
writer: AxiomByteWriter,
|
||||
container: PalettedContainerData,
|
||||
protocolVersion: Int,
|
||||
globalPaletteBits: Int,
|
||||
) {
|
||||
require(protocolVersion >= 757) { "Palette formats before Minecraft 1.18 are not supported" }
|
||||
require(globalPaletteBits in 9..31) { "Invalid global palette width: $globalPaletteBits" }
|
||||
val paletteSize = container.palette.size
|
||||
require(paletteSize in 1..BLOCK_COUNT) { "Invalid palette size: $paletteSize" }
|
||||
require(container.palette.all { it >= 0 && it.toLong() < (1L shl globalPaletteBits) }) {
|
||||
"Block state ID does not fit the target registry width"
|
||||
}
|
||||
if (paletteSize == 1) {
|
||||
writer.writeByte(0)
|
||||
writer.writeVarInt(container.palette[0])
|
||||
writeValues(writer, protocolVersion, longArrayOf())
|
||||
return
|
||||
}
|
||||
|
||||
val bitsPerValue = bitsPerValue(paletteSize, globalPaletteBits)
|
||||
writer.writeByte(bitsPerValue)
|
||||
|
||||
val values = if (bitsPerValue == globalPaletteBits) {
|
||||
IntArray(BLOCK_COUNT) { container.idAt(it) }
|
||||
} else {
|
||||
writer.writeVarInt(paletteSize)
|
||||
for (id in container.palette) {
|
||||
writer.writeVarInt(id)
|
||||
}
|
||||
IntArray(BLOCK_COUNT) { container.paletteIndexAt(it) }
|
||||
}
|
||||
|
||||
writeValues(
|
||||
writer = writer,
|
||||
protocolVersion = protocolVersion,
|
||||
values = packValues(bitsPerValue, values),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeBitsPerValue(bitsPerValue: Int, globalPaletteBits: Int): Int {
|
||||
if (bitsPerValue < 0 || bitsPerValue > BLOCK_HIGHEST_BITS_PER_VALUE) {
|
||||
return globalPaletteBits
|
||||
}
|
||||
return max(MIN_BLOCK_BITS_PER_VALUE, bitsPerValue)
|
||||
}
|
||||
|
||||
private fun bitsPerValue(paletteSize: Int, globalPaletteBits: Int): Int {
|
||||
val bits = max(MIN_BLOCK_BITS_PER_VALUE, ceilLog2(paletteSize))
|
||||
return if (bits > BLOCK_HIGHEST_BITS_PER_VALUE) globalPaletteBits else bits
|
||||
}
|
||||
|
||||
private fun readValues(reader: AxiomByteReader, protocolVersion: Int, bitsPerValue: Int): LongArray {
|
||||
if (protocolVersion >= PROTOCOL_1_21_5) {
|
||||
if (bitsPerValue == 0) {
|
||||
return longArrayOf()
|
||||
}
|
||||
return reader.readLongArray(expectedLongArrayLength(bitsPerValue))
|
||||
}
|
||||
|
||||
val length = reader.readVarInt()
|
||||
val expected = if (bitsPerValue == 0) 0 else expectedLongArrayLength(bitsPerValue)
|
||||
require(length == expected) { "Invalid palette data length: $length, expected $expected" }
|
||||
return reader.readLongArray(length)
|
||||
}
|
||||
|
||||
private fun writeValues(
|
||||
writer: AxiomByteWriter,
|
||||
protocolVersion: Int,
|
||||
values: LongArray,
|
||||
) {
|
||||
if (protocolVersion >= PROTOCOL_1_21_5) {
|
||||
writer.writeLongArray(values, includeLength = false)
|
||||
} else {
|
||||
writer.writeLongArray(values, includeLength = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectedLongArrayLength(bitsPerValue: Int): Int {
|
||||
val valuesPerLong = 64 / bitsPerValue
|
||||
return (BLOCK_COUNT + valuesPerLong - 1) / valuesPerLong
|
||||
}
|
||||
|
||||
private fun unpackValues(values: LongArray, bitsPerValue: Int): IntArray {
|
||||
val unpacked = IntArray(BLOCK_COUNT)
|
||||
if (values.isEmpty() || bitsPerValue == 0) {
|
||||
return unpacked
|
||||
}
|
||||
|
||||
val maxValue = (1L shl bitsPerValue) - 1L
|
||||
val valuesPerLong = 64 / bitsPerValue
|
||||
var index = 0
|
||||
for (cell in values) {
|
||||
var current = cell
|
||||
repeat(valuesPerLong) {
|
||||
if (index >= BLOCK_COUNT) {
|
||||
return unpacked
|
||||
}
|
||||
unpacked[index++] = (current and maxValue).toInt()
|
||||
current = current ushr bitsPerValue
|
||||
}
|
||||
}
|
||||
return unpacked
|
||||
}
|
||||
|
||||
private fun packValues(bitsPerValue: Int, values: IntArray): LongArray {
|
||||
val maxValue = (1L shl bitsPerValue) - 1L
|
||||
val valuesPerLong = 64 / bitsPerValue
|
||||
val packed = LongArray((values.size + valuesPerLong - 1) / valuesPerLong)
|
||||
|
||||
var index = 0
|
||||
for (cellIndex in packed.indices) {
|
||||
var cell = 0L
|
||||
var bitIndex = 0
|
||||
val limit = minOf(index + valuesPerLong, values.size)
|
||||
while (index < limit) {
|
||||
cell = cell or ((values[index++].toLong() and maxValue) shl bitIndex)
|
||||
bitIndex += bitsPerValue
|
||||
}
|
||||
packed[cellIndex] = cell
|
||||
}
|
||||
|
||||
return packed
|
||||
}
|
||||
|
||||
private fun ceilLog2(value: Int): Int {
|
||||
return if (value > 0) 32 - Integer.numberOfLeadingZeros(value - 1) else 0
|
||||
}
|
||||
}
|
||||
|
||||
class PalettedContainerData(
|
||||
val palette: MutableList<Int>,
|
||||
private val paletteIndexes: IntArray,
|
||||
) {
|
||||
init {
|
||||
require(paletteIndexes.size == 4096) { "A block section must contain 4096 values" }
|
||||
require(palette.isNotEmpty() && paletteIndexes.all { it in palette.indices }) { "Invalid block palette indexes" }
|
||||
}
|
||||
fun idAt(index: Int): Int = palette[paletteIndexes[index]]
|
||||
|
||||
fun paletteIndexAt(index: Int): Int = paletteIndexes[index]
|
||||
|
||||
fun replaceIds(mapper: (Int) -> Int) {
|
||||
if (palette.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val newPalette = ArrayList<Int>(palette.size)
|
||||
val indexesById = HashMap<Int, Int>(palette.size * 2)
|
||||
val remap = IntArray(palette.size)
|
||||
for (oldIndex in palette.indices) {
|
||||
val mappedId = mapper(palette[oldIndex])
|
||||
remap[oldIndex] = indexesById.getOrPut(mappedId) {
|
||||
newPalette += mappedId
|
||||
newPalette.lastIndex
|
||||
}
|
||||
}
|
||||
|
||||
for (i in paletteIndexes.indices) {
|
||||
paletteIndexes[i] = remap[paletteIndexes[i]]
|
||||
}
|
||||
|
||||
palette.clear()
|
||||
palette += newPalette
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomCustomPayloadFrameCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadDirection
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTranslationSession
|
||||
|
||||
class AxiomFrameTranslationEngine(
|
||||
private val payloadTranslator: AxiomPayloadTranslator,
|
||||
) {
|
||||
fun rewrite(
|
||||
frameBytes: ByteArray,
|
||||
customPayloadPacketId: Int,
|
||||
direction: AxiomPayloadDirection,
|
||||
session: AxiomTranslationSession,
|
||||
): ByteArray {
|
||||
return AxiomCustomPayloadFrameCodec.rewriteIfCustomPayload(
|
||||
frameBytes = frameBytes,
|
||||
customPayloadPacketId = customPayloadPacketId,
|
||||
) { frame ->
|
||||
payloadTranslator.rewrite(
|
||||
payload = AxiomPayload(
|
||||
direction = direction,
|
||||
channel = frame.channel,
|
||||
bytes = frame.payloadBytes,
|
||||
),
|
||||
session = session,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadDirection
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadKey
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomProtocol
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomSetBlockPayloadCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTranslationSession
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomSetBufferPayloadCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomResponseChunkDataPayloadCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.PalettedContainerCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomCustomBlockCodec
|
||||
|
||||
data class BlockStatePaletteWidths(val client: Int, val server: Int) {
|
||||
init {
|
||||
require(client in 9..31 && server in 9..31)
|
||||
}
|
||||
}
|
||||
|
||||
data class AxiomPayload(
|
||||
val direction: AxiomPayloadDirection,
|
||||
val channel: String,
|
||||
val bytes: ByteArray,
|
||||
) {
|
||||
val key: AxiomPayloadKey
|
||||
get() = AxiomPayloadKey(direction, channel)
|
||||
}
|
||||
|
||||
interface AxiomPayloadTranslator {
|
||||
fun rewrite(payload: AxiomPayload, session: AxiomTranslationSession): ByteArray
|
||||
}
|
||||
|
||||
class AxiomTranslationEngine(
|
||||
private val blockStateIdTranslator: BlockStateIdTranslator,
|
||||
private val paletteWidths: BlockStatePaletteWidths? = null,
|
||||
) : AxiomPayloadTranslator {
|
||||
override fun rewrite(payload: AxiomPayload, session: AxiomTranslationSession): ByteArray {
|
||||
if (!session.protocolMismatch || !AxiomProtocol.isAxiomChannel(payload.channel)) {
|
||||
return payload.bytes
|
||||
}
|
||||
|
||||
if (payload.key !in AxiomProtocol.BLOCK_STATE_TRANSLATION_CHANNELS) {
|
||||
return payload.bytes
|
||||
}
|
||||
|
||||
return when (payload.key) {
|
||||
AxiomPayloadKey(AxiomPayloadDirection.SERVERBOUND, AxiomProtocol.channel("set_block")) -> {
|
||||
rewriteSetBlock(payload.bytes)
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.SERVERBOUND, AxiomProtocol.channel("set_buffer")) -> {
|
||||
rewriteSetBuffer(payload.bytes, session)
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("response_chunk_data")) -> {
|
||||
rewriteResponseChunkData(payload.bytes, session)
|
||||
}
|
||||
AxiomPayloadKey(AxiomPayloadDirection.CLIENTBOUND, AxiomProtocol.channel("register_custom_block_v2")) -> {
|
||||
rewriteRegisterCustomBlock(payload.bytes)
|
||||
}
|
||||
else -> payload.bytes
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteSetBlock(bytes: ByteArray): ByteArray {
|
||||
return AxiomSetBlockPayloadCodec.rewriteBlockStateIds(bytes, blockStateIdTranslator::clientToServer)
|
||||
}
|
||||
|
||||
private fun rewriteSetBuffer(bytes: ByteArray, session: AxiomTranslationSession): ByteArray {
|
||||
val widths = paletteWidths ?: return bytes
|
||||
return AxiomSetBufferPayloadCodec.rewriteBlockSections(bytes) { reader, writer ->
|
||||
PalettedContainerCodec.rewriteBlockStateIds(
|
||||
reader, writer, session.hello.protocolVersion, widths.client,
|
||||
blockStateIdTranslator::clientToServer, session.serverProtocolVersion, widths.server,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteResponseChunkData(bytes: ByteArray, session: AxiomTranslationSession): ByteArray {
|
||||
val widths = paletteWidths ?: return bytes
|
||||
return AxiomResponseChunkDataPayloadCodec.rewriteSections(bytes) { reader, writer ->
|
||||
PalettedContainerCodec.rewriteBlockStateIds(
|
||||
reader, writer, session.serverProtocolVersion, widths.server,
|
||||
blockStateIdTranslator::serverToClient, session.hello.protocolVersion, widths.client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteRegisterCustomBlock(bytes: ByteArray): ByteArray {
|
||||
// Raw IDs need mapping. The codec keeps -1/string states as they are.
|
||||
return AxiomCustomBlockCodec.rewrite(bytes, blockStateIdTranslator::serverToClient)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadDirection
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTranslationSession
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTunnelCodec
|
||||
|
||||
class AxiomTunnelTranslationEngine(private val payloadTranslator: AxiomPayloadTranslator) {
|
||||
fun rewrite(fragments: List<ByteArray>, session: AxiomTranslationSession): List<ByteArray> {
|
||||
val enable = requireNotNull(session.enable) { "Axiom tunnel requires negotiated server limits" }
|
||||
val splitSize = requireNotNull(enable.tunnelSplitSize)
|
||||
val maximumSize = requireNotNull(enable.maximumTunnelPacketSize)
|
||||
val packet = AxiomTunnelCodec.decode(fragments, splitSize, maximumSize)
|
||||
require(packet.channel in enable.supportedServerboundChannels) { "Server does not support ${packet.channel}" }
|
||||
val translated = payloadTranslator.rewrite(
|
||||
AxiomPayload(AxiomPayloadDirection.SERVERBOUND, packet.channel, packet.payload), session,
|
||||
)
|
||||
if (translated.contentEquals(packet.payload)) return fragments
|
||||
return AxiomTunnelCodec.encode(packet.copy(payload = translated), splitSize, maximumSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
interface BlockStateIdTranslator {
|
||||
fun clientToServer(blockStateId: Int): Int
|
||||
|
||||
fun serverToClient(blockStateId: Int): Int
|
||||
}
|
||||
|
||||
enum class BlockStateMappingDirection {
|
||||
CLIENT_TO_SERVER,
|
||||
SERVER_TO_CLIENT,
|
||||
}
|
||||
|
||||
data class MissingBlockStatePolicy(
|
||||
val clientToServerFallbackId: Int,
|
||||
val serverToClientFallbackId: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun placeholder(fallbackId: Int): MissingBlockStatePolicy {
|
||||
return MissingBlockStatePolicy(
|
||||
clientToServerFallbackId = fallbackId,
|
||||
serverToClientFallbackId = fallbackId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object IdentityBlockStateIdTranslator : BlockStateIdTranslator {
|
||||
override fun clientToServer(blockStateId: Int): Int = blockStateId
|
||||
|
||||
override fun serverToClient(blockStateId: Int): Int = blockStateId
|
||||
}
|
||||
|
||||
class TableBlockStateIdTranslator(
|
||||
private val clientToServer: IntArray,
|
||||
private val serverToClient: IntArray,
|
||||
private val missingBlockStatePolicy: MissingBlockStatePolicy,
|
||||
private val onMissingBlockState: ((direction: BlockStateMappingDirection, missingId: Int, fallbackId: Int) -> Unit)? = null,
|
||||
) : BlockStateIdTranslator {
|
||||
constructor(
|
||||
clientToServer: IntArray,
|
||||
serverToClient: IntArray,
|
||||
fallbackId: Int,
|
||||
) : this(clientToServer, serverToClient, MissingBlockStatePolicy.placeholder(fallbackId))
|
||||
|
||||
override fun clientToServer(blockStateId: Int): Int {
|
||||
return translate(
|
||||
table = clientToServer,
|
||||
blockStateId = blockStateId,
|
||||
fallbackId = missingBlockStatePolicy.clientToServerFallbackId,
|
||||
direction = BlockStateMappingDirection.CLIENT_TO_SERVER,
|
||||
)
|
||||
}
|
||||
|
||||
override fun serverToClient(blockStateId: Int): Int {
|
||||
return translate(
|
||||
table = serverToClient,
|
||||
blockStateId = blockStateId,
|
||||
fallbackId = missingBlockStatePolicy.serverToClientFallbackId,
|
||||
direction = BlockStateMappingDirection.SERVER_TO_CLIENT,
|
||||
)
|
||||
}
|
||||
|
||||
private fun translate(
|
||||
table: IntArray,
|
||||
blockStateId: Int,
|
||||
fallbackId: Int,
|
||||
direction: BlockStateMappingDirection,
|
||||
): Int {
|
||||
if (blockStateId < 0 || blockStateId >= table.size) {
|
||||
onMissingBlockState?.invoke(direction, blockStateId, fallbackId)
|
||||
return fallbackId
|
||||
}
|
||||
|
||||
val mapped = table[blockStateId]
|
||||
if (mapped >= 0) {
|
||||
return mapped
|
||||
}
|
||||
|
||||
onMissingBlockState?.invoke(direction, blockStateId, fallbackId)
|
||||
return fallbackId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
/** ViaVersion mapping tables run server-to-client, in reverse protocol-path order. */
|
||||
object BlockStateMappingChain {
|
||||
fun compose(serverToClientSteps: List<IntArray>, clientRegistrySize: Int, clientStoneId: Int): Pair<IntArray, IntArray> {
|
||||
require(clientRegistrySize > 0 && clientStoneId in 0 until clientRegistrySize)
|
||||
val serverSize = serverToClientSteps.firstOrNull()?.size ?: clientRegistrySize
|
||||
require(serverSize > 0)
|
||||
val serverToClient = IntArray(serverSize) { id ->
|
||||
var mapped = id
|
||||
for (step in serverToClientSteps) mapped = if (mapped in step.indices) step[mapped] else -1
|
||||
if (mapped in 0 until clientRegistrySize) mapped else -1
|
||||
}
|
||||
val clientToServer = IntArray(clientRegistrySize) { -1 }
|
||||
// On collisions, keep the earliest canonical state, as upstream Axiom does.
|
||||
for (serverId in serverToClient.indices.reversed()) {
|
||||
val clientId = serverToClient[serverId]
|
||||
if (clientId >= 0) clientToServer[clientId] = serverId
|
||||
}
|
||||
require(clientToServer[clientStoneId] >= 0) { "Mapping chain cannot map the stone placeholder" }
|
||||
return clientToServer to serverToClient
|
||||
}
|
||||
|
||||
/** Block states with no counterpart become stone. */
|
||||
fun translationEngine(
|
||||
serverToClientSteps: List<IntArray>,
|
||||
clientRegistrySize: Int,
|
||||
clientStoneId: Int,
|
||||
onMissingBlockState: (direction: BlockStateMappingDirection, missingId: Int, fallbackId: Int) -> Unit,
|
||||
): AxiomTranslationEngine {
|
||||
val (clientToServer, serverToClient) = compose(serverToClientSteps, clientRegistrySize, clientStoneId)
|
||||
val translator = TableBlockStateIdTranslator(clientToServer, serverToClient,
|
||||
MissingBlockStatePolicy(clientToServer[clientStoneId], clientStoneId), onMissingBlockState)
|
||||
return AxiomTranslationEngine(translator, BlockStatePaletteWidths(paletteBits(clientRegistrySize), paletteBits(serverToClient.size)))
|
||||
}
|
||||
|
||||
private fun paletteBits(registrySize: Int) = maxOf(9, 32 - Integer.numberOfLeadingZeros(registrySize - 1))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package me.wargearsystems.viaaxiom.binary
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class AxiomByteCodecTest {
|
||||
@Test
|
||||
fun `varint round trips minecraft edge values`() {
|
||||
val values = listOf(0, 1, 2, 127, 128, 255, 25565, Int.MAX_VALUE, -1, Int.MIN_VALUE)
|
||||
|
||||
for (value in values) {
|
||||
val bytes = AxiomByteWriter().writeVarInt(value).toByteArray()
|
||||
val reader = AxiomByteReader(bytes)
|
||||
|
||||
assertEquals(value, reader.readVarInt())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primitive values round trip in network byte order`() {
|
||||
val bytes = AxiomByteWriter()
|
||||
.writeBoolean(true)
|
||||
.writeByte(0xFE)
|
||||
.writeShort(0x3456)
|
||||
.writeInt(0x12345678)
|
||||
.writeLong(-9223371899415820288L)
|
||||
.writeByteArray(byteArrayOf(9, 8, 7))
|
||||
.toByteArray()
|
||||
|
||||
val reader = AxiomByteReader(bytes)
|
||||
|
||||
assertEquals(true, reader.readBoolean())
|
||||
assertEquals(0xFE, reader.readUnsignedByte())
|
||||
assertEquals(0x3456, reader.readShort().toInt())
|
||||
assertEquals(0x12345678, reader.readInt())
|
||||
assertEquals(-9223371899415820288L, reader.readLong())
|
||||
assertContentEquals(byteArrayOf(9, 8, 7), reader.readByteArray())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `utf strings round trip`() {
|
||||
val bytes = AxiomByteWriter()
|
||||
.writeUtf("axiom:hello")
|
||||
.writeUtf("stone")
|
||||
.toByteArray()
|
||||
|
||||
val reader = AxiomByteReader(bytes)
|
||||
|
||||
assertEquals("axiom:hello", reader.readUtf())
|
||||
assertEquals("stone", reader.readUtf())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy remaining preserves unread bytes`() {
|
||||
val bytes = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val reader = AxiomByteReader(bytes)
|
||||
|
||||
assertEquals(1, reader.readUnsignedByte())
|
||||
assertContentEquals(byteArrayOf(2, 3, 4, 5), reader.copyRemaining())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized varint fails`() {
|
||||
val oversized = byteArrayOf(0x80.toByte(), 0x80.toByte(), 0x80.toByte(), 0x80.toByte(), 0x80.toByte(), 0x01)
|
||||
|
||||
assertFailsWith<AxiomByteCodecException> {
|
||||
AxiomByteReader(oversized).readVarInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomCustomBlockCodecTest {
|
||||
@Test fun `raw states and rotation mappings are rewritten`() {
|
||||
val output = AxiomCustomBlockCodec.rewrite(packet(false)) { it + 100 }
|
||||
val reader = AxiomByteReader(output)
|
||||
assertEquals("test:block", reader.readUtf())
|
||||
assertEquals("test.translation", reader.readUtf())
|
||||
assertEquals(0, reader.readVarInt())
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(105, reader.readVarInt())
|
||||
repeat(4) { reader.readBoolean() }
|
||||
assertEquals(0, reader.readVarInt())
|
||||
reader.readBoolean()
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(105, reader.readVarInt())
|
||||
assertEquals(106, reader.readVarInt())
|
||||
repeat(4) { assertEquals(0, reader.readVarInt()) }
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test fun `string based mismatch states preserve their layout`() {
|
||||
val input = packet(true)
|
||||
assertContentEquals(input, AxiomCustomBlockCodec.rewrite(input) { error("String states should not map") })
|
||||
}
|
||||
|
||||
@Test fun `embedded item components fail explicitly instead of decoding wrong registry`() {
|
||||
val input = AxiomByteWriter().writeUtf("test:block").writeUtf("test.translation")
|
||||
.writeVarInt(0).writeVarInt(0).writeBoolean(true).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { AxiomCustomBlockCodec.rewrite(input) { it } }
|
||||
}
|
||||
|
||||
private fun packet(strings: Boolean): ByteArray {
|
||||
val writer = AxiomByteWriter().writeUtf("test:block").writeUtf("test.translation").writeVarInt(0).writeVarInt(1)
|
||||
fun state(id: Int) { if (strings) writer.writeVarInt(-1).writeUtf("minecraft:stone") else writer.writeVarInt(id) }
|
||||
state(5)
|
||||
repeat(4) { writer.writeBoolean(false) }
|
||||
writer.writeVarInt(0).writeBoolean(true).writeVarInt(1)
|
||||
state(5); state(6)
|
||||
repeat(4) { writer.writeVarInt(0) }
|
||||
return writer.toByteArray()
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AxiomCustomPayloadFrameCodecTest {
|
||||
@Test
|
||||
fun `reads custom payload frame when packet id matches`() {
|
||||
val frameBytes = frameBytes(packetId = 12, channel = "axiom:hello", payload = byteArrayOf(1, 2, 3))
|
||||
|
||||
val frame = AxiomCustomPayloadFrameCodec.readIfCustomPayload(frameBytes, customPayloadPacketId = 12)
|
||||
|
||||
requireNotNull(frame)
|
||||
assertEquals(12, frame.packetId)
|
||||
assertEquals("axiom:hello", frame.channel)
|
||||
assertContentEquals(byteArrayOf(1, 2, 3), frame.payloadBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads custom payload frame without a known packet id`() {
|
||||
val frameBytes = frameBytes(packetId = 12, channel = "axiom:hello", payload = byteArrayOf(1, 2, 3))
|
||||
|
||||
val frame = AxiomCustomPayloadFrameCodec.read(frameBytes)
|
||||
|
||||
assertEquals(12, frame.packetId)
|
||||
assertEquals("axiom:hello", frame.channel)
|
||||
assertContentEquals(byteArrayOf(1, 2, 3), frame.payloadBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when packet id does not match`() {
|
||||
val frameBytes = frameBytes(packetId = 11, channel = "axiom:hello", payload = byteArrayOf(1, 2, 3))
|
||||
|
||||
assertNull(AxiomCustomPayloadFrameCodec.readIfCustomPayload(frameBytes, customPayloadPacketId = 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated packet is not parsed as a custom payload`() {
|
||||
val frameBytes = byteArrayOf(11, 127)
|
||||
assertNull(AxiomCustomPayloadFrameCodec.readIfCustomPayload(frameBytes, customPayloadPacketId = 12))
|
||||
assertContentEquals(frameBytes, AxiomCustomPayloadFrameCodec.rewriteIfCustomPayload(frameBytes, 12) {
|
||||
error("Unrelated packets must not be rewritten")
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites only the payload bytes`() {
|
||||
val original = frameBytes(packetId = 12, channel = "axiom:set_block", payload = byteArrayOf(1, 2, 3))
|
||||
|
||||
val rewritten = AxiomCustomPayloadFrameCodec.rewriteIfCustomPayload(original, customPayloadPacketId = 12) {
|
||||
byteArrayOf(9, 8, 7)
|
||||
}
|
||||
val frame = AxiomCustomPayloadFrameCodec.readIfCustomPayload(rewritten, customPayloadPacketId = 12)
|
||||
|
||||
requireNotNull(frame)
|
||||
assertEquals(12, frame.packetId)
|
||||
assertEquals("axiom:set_block", frame.channel)
|
||||
assertContentEquals(byteArrayOf(9, 8, 7), frame.payloadBytes)
|
||||
}
|
||||
|
||||
private fun frameBytes(packetId: Int, channel: String, payload: ByteArray): ByteArray {
|
||||
return AxiomByteWriter()
|
||||
.writeVarInt(packetId)
|
||||
.writeUtf(channel)
|
||||
.writeBytes(payload)
|
||||
.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomEnableCodecTest {
|
||||
@Test
|
||||
fun `enable payload header round trips`() {
|
||||
val enable = AxiomEnable(
|
||||
enabled = true,
|
||||
maxBufferSize = 1_048_576,
|
||||
blueprintVersion = 2,
|
||||
customDataOverridesCount = 0,
|
||||
rotationOverridesCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(enable, AxiomEnableCodec.read(AxiomEnableCodec.write(enable)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AxiomHelloCodecTest {
|
||||
@Test
|
||||
fun `hello payload round trips`() {
|
||||
val hello = AxiomHello(
|
||||
apiVersion = 9,
|
||||
dataVersion = 4438,
|
||||
protocolVersion = 773,
|
||||
)
|
||||
|
||||
assertEquals(hello, AxiomHelloCodec.read(AxiomHelloCodec.write(hello)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hello exposes api compatibility`() {
|
||||
assertTrue(AxiomHello(9, 4438, 773).isSupportedApi)
|
||||
assertFalse(AxiomHello(8, 4438, 773).isSupportedApi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `translation session exposes protocol and data mismatches`() {
|
||||
val session = AxiomTranslationSession(
|
||||
hello = AxiomHello(apiVersion = 9, dataVersion = 4438, protocolVersion = 772),
|
||||
serverProtocolVersion = 773,
|
||||
serverDataVersion = 4438,
|
||||
)
|
||||
|
||||
assertTrue(session.protocolMismatch)
|
||||
assertFalse(session.dataVersionMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.translation.*
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomLegacyBridgeTest {
|
||||
private val challenge = 42L
|
||||
private val channels = "axiom:hello\u0000axiom:set_block\u0000axiom:set_buffer\u0000axiom:request_chunk_data"
|
||||
private val engine = AxiomTranslationEngine(TableBlockStateIdTranslator(
|
||||
intArrayOf(0, 2, 1), intArrayOf(0, 2, 1), 0), BlockStatePaletteWidths(15, 15))
|
||||
|
||||
@Test fun `registration starts legacy handshake only once`() {
|
||||
val bridge = bridge()
|
||||
val frame = assertNotNull(bridge.createChallenge(24))
|
||||
assertEquals(24, frame.packetId)
|
||||
assertEquals("axiom:hello", frame.channel)
|
||||
assertContentEquals(AxiomByteWriter().writeLong(challenge).toByteArray(), frame.payloadBytes)
|
||||
assertNull(bridge.createChallenge(24))
|
||||
}
|
||||
|
||||
@Test fun `native API 10 server is never downgraded`() {
|
||||
val bridge = bridge()
|
||||
bridge.register("axiom:tunnel".toByteArray())
|
||||
assertNull(bridge.createChallenge(24))
|
||||
val hello = modernHello()
|
||||
assertSame(hello, bridge.serverbound(hello))
|
||||
}
|
||||
|
||||
@Test fun `real challenge supersedes pending legacy detection`() {
|
||||
val bridge = bridge()
|
||||
bridge.createChallenge(24)
|
||||
val actual = AxiomCustomPayloadFrame(24, "axiom:hello", AxiomByteWriter().writeLong(99).toByteArray())
|
||||
assertSame(actual, bridge.clientbound(actual))
|
||||
assertFalse(bridge.active)
|
||||
val reply = modernHello()
|
||||
assertSame(reply, bridge.serverbound(reply))
|
||||
}
|
||||
|
||||
@Test fun `client hello retains data version but advertises API 9 and server registry protocol`() {
|
||||
val bridge = bridge()
|
||||
bridge.createChallenge(24)
|
||||
val output = assertNotNull(bridge.serverbound(modernHello()))
|
||||
assertEquals(AxiomHello(9, 5000, 771), AxiomHelloCodec.read(output.payloadBytes))
|
||||
}
|
||||
|
||||
@Test fun `wrong challenge cannot activate bridge`() {
|
||||
val bridge = bridge()
|
||||
bridge.createChallenge(24)
|
||||
assertFailsWith<IllegalArgumentException> { bridge.serverbound(modernHello(99)) }
|
||||
assertFailsWith<IllegalArgumentException> { bridge.clientbound(legacyEnable()) }
|
||||
}
|
||||
|
||||
@Test fun `server enable is converted to modern config with only registered capabilities`() {
|
||||
val bridge = negotiated()
|
||||
val output = bridge.clientbound(legacyEnable())
|
||||
val modern = AxiomEnableCodec.read(output.payloadBytes, 10)
|
||||
assertTrue(modern.enabled)
|
||||
assertEquals(0, modern.serverConfigVersion)
|
||||
assertEquals(1_048_576, modern.maximumTunnelPacketSize)
|
||||
assertTrue("axiom:tunnel" in modern.supportedServerboundChannels)
|
||||
assertTrue("axiom:set_buffer" in modern.supportedServerboundChannels)
|
||||
assertFalse("axiom:tick_blocks" in modern.supportedServerboundChannels)
|
||||
}
|
||||
|
||||
@Test fun `server refusal remains disabled and prevents tunneling`() {
|
||||
val bridge = negotiated()
|
||||
val output = bridge.clientbound(legacyEnable(false))
|
||||
assertFalse(AxiomEnableCodec.read(output.payloadBytes, 10).enabled)
|
||||
assertFailsWith<IllegalArgumentException> { bridge.serverbound(tunnel("axiom:set_block", byteArrayOf(0)).single()) }
|
||||
}
|
||||
|
||||
@Test fun `compressed multi fragment tunnel becomes one legacy payload`() {
|
||||
val body = ByteArray(90_000) { ((it * 31 + it / 17) and 255).toByte() }
|
||||
val frames = tunnel("axiom:set_buffer", body, compressed = true, split = 64)
|
||||
assertTrue(frames.size > 1)
|
||||
// Test transport independently; an opaque payload translator preserves this fixture.
|
||||
val transportBridge = AxiomLegacyBridge(771, object : AxiomPayloadTranslator {
|
||||
override fun rewrite(payload: AxiomPayload, session: AxiomTranslationSession) = payload.bytes
|
||||
}) { challenge }
|
||||
transportBridge.register(channels.toByteArray())
|
||||
transportBridge.createChallenge(24)
|
||||
transportBridge.serverbound(modernHello())
|
||||
transportBridge.clientbound(legacyEnable())
|
||||
frames.dropLast(1).forEach { assertNull(transportBridge.serverbound(it)) }
|
||||
val output = assertNotNull(transportBridge.serverbound(frames.last()))
|
||||
assertEquals("axiom:set_buffer", output.channel)
|
||||
assertContentEquals(body, output.payloadBytes)
|
||||
}
|
||||
|
||||
@Test fun `block IDs are rewritten after extracting tunnel`() {
|
||||
val body = AxiomByteWriter().writeVarInt(1).writeLong(123).writeVarInt(1).toByteArray()
|
||||
val output = assertNotNull(enabled().serverbound(tunnel("axiom:set_block", body).single()))
|
||||
assertContentEquals(AxiomByteWriter().writeVarInt(1).writeLong(123).writeVarInt(2).toByteArray(), output.payloadBytes)
|
||||
}
|
||||
|
||||
@Test fun `unregistered channels and out of order fragments fail closed`() {
|
||||
assertFailsWith<IllegalArgumentException> { enabled().serverbound(tunnel("axiom:tick_blocks", byteArrayOf(0)).single()) }
|
||||
val continuation = AxiomCustomPayloadFrame(20, "axiom:tunnel", byteArrayOf(2, 1))
|
||||
assertFailsWith<IllegalArgumentException> { enabled().serverbound(continuation) }
|
||||
}
|
||||
|
||||
@Test fun `redo handshake clears buffered fragments and requires new hello`() {
|
||||
val bridge = enabled()
|
||||
val fragmented = tunnel("axiom:set_buffer", ByteArray(100), split = 32)
|
||||
assertNull(bridge.serverbound(fragmented.first()))
|
||||
val redo = bridge.clientbound(AxiomCustomPayloadFrame(24, "axiom:redo_handshake", byteArrayOf()))
|
||||
assertEquals("axiom:hello", redo.channel)
|
||||
assertFailsWith<IllegalArgumentException> { bridge.serverbound(fragmented.last()) }
|
||||
bridge.serverbound(modernHello())
|
||||
bridge.clientbound(legacyEnable())
|
||||
val body = byteArrayOf(0)
|
||||
assertNotNull(bridge.serverbound(tunnel("axiom:set_block", body).single()))
|
||||
}
|
||||
|
||||
@Test fun `nonempty legacy overrides are rejected`() {
|
||||
val bridge = negotiated()
|
||||
val payload = AxiomByteWriter().writeBoolean(true).writeInt(1000).writeVarInt(2).writeVarInt(1).writeVarInt(0).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { bridge.clientbound(AxiomCustomPayloadFrame(24, "axiom:enable", payload)) }
|
||||
}
|
||||
|
||||
private fun bridge() = AxiomLegacyBridge(771, engine) { challenge }.also { it.register(channels.toByteArray()) }
|
||||
private fun negotiated() = bridge().also { it.createChallenge(24); it.serverbound(modernHello()) }
|
||||
private fun enabled() = negotiated().also { it.clientbound(legacyEnable()) }
|
||||
private fun modernHello(id: Long = challenge) = AxiomCustomPayloadFrame(20, "axiom:hello", AxiomHelloCodec.write(AxiomHello(10, 5000, 776, id)))
|
||||
private fun legacyEnable(enabled: Boolean = true) = AxiomCustomPayloadFrame(24, "axiom:enable", AxiomEnableCodec.write(AxiomEnable(enabled, 1_048_576, 2, 0, 0), 9))
|
||||
private fun tunnel(channel: String, bytes: ByteArray, compressed: Boolean = false, split: Int = 31_000) =
|
||||
AxiomTunnelCodec.encode(AxiomTunnelPacket(channel, bytes, compressed), split).map { AxiomCustomPayloadFrame(20, "axiom:tunnel", it) }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomModernHandshakeTest {
|
||||
private val challenge = 0x0102030405060708L
|
||||
private val helloBytes = AxiomByteWriter().writeVarInt(10).writeVarInt(5000)
|
||||
.writeVarInt(775).writeLong(challenge).toByteArray()
|
||||
private val enableBytes = AxiomByteWriter().writeBoolean(true).writeByte(0).writeVarInt(2)
|
||||
.writeInt(31_000).writeInt(2_097_152).writeVarInt(0).writeVarInt(0)
|
||||
.writeVarInt(2).writeUtf("axiom:tunnel").writeUtf("axiom:set_buffer").toByteArray()
|
||||
|
||||
@Test fun `API 10 hello preserves the server-issued ID`() {
|
||||
val hello = AxiomHelloCodec.read(helloBytes)
|
||||
assertEquals(challenge, hello.handshakeId)
|
||||
assertTrue(hello.isSupportedApi)
|
||||
assertContentEquals(helloBytes, AxiomHelloCodec.write(hello))
|
||||
}
|
||||
|
||||
@Test fun `API 10 hello rejects missing challenge ID and unknown API`() {
|
||||
assertFailsWith<IllegalArgumentException> { AxiomHelloCodec.read(helloBytes.copyOf(helloBytes.size - 8)) }
|
||||
assertFailsWith<IllegalArgumentException> { AxiomHelloCodec.write(AxiomHello(10, 5000, 775)) }
|
||||
assertFailsWith<IllegalArgumentException> { AxiomHelloCodec.read(byteArrayOf(11, 0, 0)) }
|
||||
}
|
||||
|
||||
@Test fun `API 10 enable decodes upstream server config without using legacy buffer layout`() {
|
||||
val enable = AxiomEnableCodec.read(enableBytes, 10)
|
||||
assertEquals(0, enable.serverConfigVersion)
|
||||
assertEquals(31_000, enable.tunnelSplitSize)
|
||||
assertEquals(2_097_152, enable.maximumTunnelPacketSize)
|
||||
assertEquals(listOf("axiom:tunnel", "axiom:set_buffer"), enable.supportedServerboundChannels)
|
||||
assertContentEquals(enableBytes, AxiomEnableCodec.write(enable, 10))
|
||||
}
|
||||
|
||||
@Test fun `unknown server config and nonempty overrides reject unsupported layout`() {
|
||||
val changedVersion = enableBytes.copyOf().also { it[1] = 1 }
|
||||
assertFailsWith<IllegalArgumentException> { AxiomEnableCodec.read(changedVersion, 10) }
|
||||
val overrides = AxiomByteWriter().writeBoolean(true).writeByte(0).writeVarInt(2)
|
||||
.writeInt(31_000).writeInt(2_097_152).writeVarInt(1).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { AxiomEnableCodec.read(overrides, 10) }
|
||||
}
|
||||
|
||||
@Test fun `handshake is directional and does not invent a server version`() {
|
||||
val tracker = negotiatedTracker()
|
||||
assertEquals(challenge, tracker.challengeId)
|
||||
assertEquals(775, tracker.hello?.protocolVersion)
|
||||
assertNull(tracker.session(null))
|
||||
assertTrue(assertNotNull(tracker.session(773)).protocolMismatch)
|
||||
tracker.observe(AxiomPayloadDirection.SERVERBOUND, "axiom:goodbye", byteArrayOf())
|
||||
assertNotNull(tracker.enable)
|
||||
}
|
||||
|
||||
@Test fun `wrong challenge never creates a session`() {
|
||||
val tracker = AxiomHandshakeTracker()
|
||||
tracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:hello", AxiomByteWriter().writeLong(challenge + 1).toByteArray())
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
tracker.observe(AxiomPayloadDirection.SERVERBOUND, "axiom:hello", helloBytes)
|
||||
}
|
||||
assertNull(tracker.hello)
|
||||
assertNull(tracker.session(773))
|
||||
}
|
||||
|
||||
@Test fun `redo handshake and goodbye clear old negotiation`() {
|
||||
val tracker = negotiatedTracker()
|
||||
tracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:redo_handshake", byteArrayOf())
|
||||
assertNull(tracker.hello)
|
||||
assertNull(tracker.enable)
|
||||
assertNull(tracker.challengeId)
|
||||
val goodbyeTracker = negotiatedTracker()
|
||||
goodbyeTracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:goodbye", AxiomByteWriter().writeUtf("Missing axiom.use permission").toByteArray())
|
||||
assertNull(goodbyeTracker.session(773))
|
||||
assertEquals("Missing axiom.use permission", goodbyeTracker.goodbyeReason)
|
||||
}
|
||||
|
||||
@Test fun `disabled and malformed enable invalidate previous session`() {
|
||||
val tracker = negotiatedTracker()
|
||||
tracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:enable", byteArrayOf(0))
|
||||
assertNull(tracker.session(773))
|
||||
assertFalse(assertNotNull(tracker.enable).enabled)
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
tracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:enable", byteArrayOf(1, 3))
|
||||
}
|
||||
assertNull(tracker.enable)
|
||||
}
|
||||
|
||||
@Test fun `legacy handshake still negotiates API 9`() {
|
||||
val tracker = AxiomHandshakeTracker()
|
||||
tracker.observe(AxiomPayloadDirection.SERVERBOUND, "axiom:hello", byteArrayOf(9, 0, 1))
|
||||
tracker.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:enable", AxiomEnableCodec.write(AxiomEnable(true, 1000, 2, 0, 0)))
|
||||
assertEquals(9, assertNotNull(tracker.session(2)).hello.apiVersion)
|
||||
}
|
||||
|
||||
private fun negotiatedTracker() = AxiomHandshakeTracker().also {
|
||||
it.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:hello", AxiomByteWriter().writeLong(challenge).toByteArray())
|
||||
it.observe(AxiomPayloadDirection.SERVERBOUND, "axiom:hello", helloBytes)
|
||||
it.observe(AxiomPayloadDirection.CLIENTBOUND, "axiom:enable", enableBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.translation.*
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomProxySessionTest {
|
||||
private val challenge = 42L
|
||||
private val legacyChannels = "axiom:hello\u0000axiom:set_block\u0000axiom:set_buffer"
|
||||
private val modernChannels = "$legacyChannels\u0000axiom:tunnel"
|
||||
private val engine = AxiomTranslationEngine(TableBlockStateIdTranslator(
|
||||
intArrayOf(0, 2, 1), intArrayOf(0, 2, 1), 0), BlockStatePaletteWidths(15, 15))
|
||||
|
||||
@Test fun `API 9 backend is bridged`() {
|
||||
val session = session()
|
||||
session.backendRegistered(legacyChannels.toByteArray())
|
||||
assertTrue(session.awaitingLegacyChallenge)
|
||||
assertEquals("axiom:hello", assertNotNull(session.startLegacyHandshake()).channel)
|
||||
val hello = session.serverbound(modernHello()).single()
|
||||
assertEquals(AxiomHello(9, 5000, 771), AxiomHelloCodec.read(hello.payloadBytes))
|
||||
val enable = session.clientbound(frame("axiom:enable", AxiomEnableCodec.write(AxiomEnable(true, 1_048_576, 2, 0, 0), 9)))
|
||||
assertTrue(AxiomEnableCodec.read(enable.payloadBytes, 10).enabled)
|
||||
}
|
||||
|
||||
@Test fun `API 10 backend on another version gets the backend protocol in the hello`() {
|
||||
val session = enabled()
|
||||
assertFalse(session.awaitingLegacyChallenge)
|
||||
session.clientbound(frame("axiom:goodbye", AxiomByteWriter().writeUtf("again").toByteArray()))
|
||||
session.clientbound(challengeFrame())
|
||||
val hello = session.serverbound(modernHello()).single()
|
||||
assertEquals(AxiomHello(10, 5000, 771, challenge), AxiomHelloCodec.read(hello.payloadBytes))
|
||||
}
|
||||
|
||||
@Test fun `API 10 small payloads are translated`() {
|
||||
val output = enabled().serverbound(frame("axiom:set_block", setBlock(1))).single()
|
||||
assertContentEquals(setBlock(2), output.payloadBytes)
|
||||
}
|
||||
|
||||
@Test fun `API 10 tunnel is translated and keeps server split size`() {
|
||||
val fragments = AxiomTunnelCodec.encode(AxiomTunnelPacket("axiom:set_block", setBlock(1)), SPLIT)
|
||||
val output = enabled().serverbound(frame("axiom:tunnel", fragments.single()))
|
||||
val packet = AxiomTunnelCodec.decode(output.map { it.payloadBytes }, SPLIT)
|
||||
assertEquals("axiom:set_block", packet.channel)
|
||||
assertContentEquals(setBlock(2), packet.payload)
|
||||
}
|
||||
|
||||
@Test fun `API 10 fragments are held until the last one`() {
|
||||
val body = AxiomByteWriter().writeVarInt(50).apply { repeat(50) { writeLong(it.toLong()).writeVarInt(1) } }.toByteArray()
|
||||
val fragments = AxiomTunnelCodec.encode(AxiomTunnelPacket("axiom:set_block", body), 32)
|
||||
assertTrue(fragments.size > 1)
|
||||
val session = enabled(split = 32)
|
||||
fragments.dropLast(1).forEach { assertTrue(session.serverbound(frame("axiom:tunnel", it)).isEmpty()) }
|
||||
val output = session.serverbound(frame("axiom:tunnel", fragments.last()))
|
||||
val packet = AxiomTunnelCodec.decode(output.map { it.payloadBytes }, 32)
|
||||
val expected = AxiomByteWriter().writeVarInt(50).apply { repeat(50) { writeLong(it.toLong()).writeVarInt(2) } }.toByteArray()
|
||||
assertContentEquals(expected, packet.payload)
|
||||
}
|
||||
|
||||
@Test fun `tunnel continuation without a first fragment fails closed`() {
|
||||
assertFailsWith<IllegalArgumentException> { enabled().serverbound(frame("axiom:tunnel", byteArrayOf(2, 1))) }
|
||||
}
|
||||
|
||||
@Test fun `API 10 matching versions pass through untouched`() {
|
||||
val session = AxiomProxySession(776, 776, engine) { challenge }
|
||||
session.backendRegistered(modernChannels.toByteArray())
|
||||
session.clientbound(challengeFrame())
|
||||
val hello = modernHello()
|
||||
assertSame(hello, session.serverbound(hello).single())
|
||||
val tunnel = frame("axiom:tunnel", byteArrayOf(3, 1))
|
||||
assertSame(tunnel, session.serverbound(tunnel).single())
|
||||
}
|
||||
|
||||
@Test fun `matching versions with an API 9 backend still bridge the API`() {
|
||||
val session = AxiomProxySession(771, 771, engine) { challenge }
|
||||
session.backendRegistered(legacyChannels.toByteArray())
|
||||
assertNotNull(session.startLegacyHandshake())
|
||||
val hello = session.serverbound(frame("axiom:hello", AxiomHelloCodec.write(AxiomHello(10, 5000, 771, challenge)))).single()
|
||||
assertEquals(9, AxiomHelloCodec.read(hello.payloadBytes).apiVersion)
|
||||
}
|
||||
|
||||
@Test fun `API 9 client is left to negotiate directly`() {
|
||||
val session = session()
|
||||
session.backendRegistered(legacyChannels.toByteArray())
|
||||
val hello = frame("axiom:hello", AxiomHelloCodec.write(AxiomHello(9, 5000, 776)))
|
||||
assertSame(hello, session.serverbound(hello).single())
|
||||
assertTrue(session.passthrough)
|
||||
assertFalse(session.awaitingLegacyChallenge)
|
||||
val edit = frame("axiom:set_block", setBlock(1))
|
||||
assertSame(edit, session.serverbound(edit).single())
|
||||
}
|
||||
|
||||
@Test fun `unknown client API does not break the session`() {
|
||||
val session = session()
|
||||
val hello = frame("axiom:hello", AxiomByteWriter().writeVarInt(11).toByteArray())
|
||||
assertSame(hello, session.serverbound(hello).single())
|
||||
assertTrue(session.passthrough)
|
||||
}
|
||||
|
||||
@Test fun `unknown enable format fails rather than running untranslated`() {
|
||||
val session = session()
|
||||
session.backendRegistered(modernChannels.toByteArray())
|
||||
session.clientbound(challengeFrame())
|
||||
session.serverbound(modernHello())
|
||||
val enable = AxiomByteWriter().writeBoolean(true).writeByte(7).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { session.clientbound(frame("axiom:enable", enable)) }
|
||||
}
|
||||
|
||||
private fun session() = AxiomProxySession(776, 771, engine) { challenge }
|
||||
private fun enabled(split: Int = SPLIT) = session().also {
|
||||
it.backendRegistered(modernChannels.toByteArray())
|
||||
it.clientbound(challengeFrame())
|
||||
it.serverbound(modernHello())
|
||||
it.clientbound(frame("axiom:enable", AxiomEnableCodec.write(AxiomEnable(
|
||||
enabled = true, blueprintVersion = 2, customDataOverridesCount = 0, rotationOverridesCount = 0,
|
||||
serverConfigVersion = 0, tunnelSplitSize = split, maximumTunnelPacketSize = 2_097_152,
|
||||
supportedServerboundChannels = listOf("axiom:set_block", "axiom:set_buffer", "axiom:tunnel")), 10)))
|
||||
}
|
||||
private fun frame(channel: String, bytes: ByteArray) = AxiomCustomPayloadFrame(0, channel, bytes)
|
||||
private fun challengeFrame() = frame("axiom:hello", AxiomByteWriter().writeLong(challenge).toByteArray())
|
||||
private fun modernHello() = frame("axiom:hello", AxiomHelloCodec.write(AxiomHello(10, 5000, 776, challenge)))
|
||||
private fun setBlock(id: Int) = AxiomByteWriter().writeVarInt(1).writeLong(123).writeVarInt(id).toByteArray()
|
||||
|
||||
private companion object {
|
||||
const val SPLIT = 31_000
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomResponseChunkDataPayloadCodecTest {
|
||||
@Test
|
||||
fun `rewrites present section payloads and preserves response structure`() {
|
||||
val original = responsePayload(
|
||||
sectionPayload = byteArrayOf(1, 2, 3),
|
||||
finished = false,
|
||||
)
|
||||
|
||||
val rewritten = AxiomResponseChunkDataPayloadCodec.rewriteSections(original) { reader, writer ->
|
||||
assertContentEquals(byteArrayOf(1, 2, 3), reader.readByteArray())
|
||||
writer.writeByteArray(byteArrayOf(9, 8, 7))
|
||||
}
|
||||
|
||||
val reader = AxiomByteReader(rewritten)
|
||||
assertEquals(42L, reader.readLong())
|
||||
assertBlockEntityCopied(reader)
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertEquals(0x2122232425262728L, reader.readLong())
|
||||
assertEquals(true, reader.readBoolean())
|
||||
assertContentEquals(byteArrayOf(9, 8, 7), reader.readByteArray())
|
||||
assertEquals(0x3132333435363738L, reader.readLong())
|
||||
assertEquals(false, reader.readBoolean())
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertEquals(false, reader.readBoolean())
|
||||
assertEquals(99, reader.readVarInt())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
private fun responsePayload(sectionPayload: ByteArray, finished: Boolean): ByteArray {
|
||||
val writer = AxiomByteWriter()
|
||||
.writeLong(42L)
|
||||
writeBlockEntity(writer)
|
||||
writer
|
||||
.writeLong(AxiomProtocol.MIN_POSITION_LONG)
|
||||
.writeLong(0x2122232425262728L)
|
||||
.writeBoolean(true)
|
||||
.writeByteArray(sectionPayload)
|
||||
.writeLong(0x3132333435363738L)
|
||||
.writeBoolean(false)
|
||||
.writeLong(AxiomProtocol.MIN_POSITION_LONG)
|
||||
.writeBoolean(finished)
|
||||
.writeVarInt(99)
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private fun writeBlockEntity(writer: AxiomByteWriter) {
|
||||
writer
|
||||
.writeLong(0x0102030405060708L)
|
||||
.writeVarInt(1234)
|
||||
.writeByte(0)
|
||||
.writeByteArray(byteArrayOf(5, 4, 3))
|
||||
}
|
||||
|
||||
private fun assertBlockEntityCopied(reader: AxiomByteReader) {
|
||||
assertEquals(0x0102030405060708L, reader.readLong())
|
||||
assertEquals(1234, reader.readVarInt())
|
||||
assertEquals(0, reader.readUnsignedByte())
|
||||
assertContentEquals(byteArrayOf(5, 4, 3), reader.readByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomSetBlockPayloadCodecTest {
|
||||
@Test
|
||||
fun `rewrites only block state ids in leading block map`() {
|
||||
val passthroughTail = AxiomByteWriter()
|
||||
.writeBoolean(true)
|
||||
.writeVarInt(1)
|
||||
.writeLong(0x1112131415161718L)
|
||||
.writeVarInt(128)
|
||||
.writeBoolean(false)
|
||||
.writeLong(0x2122232425262728L)
|
||||
.writeVarInt(2)
|
||||
.writeVarInt(0)
|
||||
.toByteArray()
|
||||
|
||||
val payload = AxiomByteWriter()
|
||||
.writeVarInt(2)
|
||||
.writeLong(0x0102030405060708L)
|
||||
.writeVarInt(10)
|
||||
.writeLong(0x090A0B0C0D0E0F10L)
|
||||
.writeVarInt(20)
|
||||
.writeBytes(passthroughTail)
|
||||
.toByteArray()
|
||||
|
||||
val rewritten = AxiomSetBlockPayloadCodec.rewriteBlockStateIds(payload) { id -> id + 1000 }
|
||||
val reader = AxiomByteReader(rewritten)
|
||||
|
||||
assertEquals(2, reader.readVarInt())
|
||||
assertEquals(0x0102030405060708L, reader.readLong())
|
||||
assertEquals(1010, reader.readVarInt())
|
||||
assertEquals(0x090A0B0C0D0E0F10L, reader.readLong())
|
||||
assertEquals(1020, reader.readVarInt())
|
||||
assertContentEquals(passthroughTail, reader.copyRemaining())
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomSetBufferPayloadCodecTest {
|
||||
@Test
|
||||
fun `rewrites block section payloads and preserves block entities plus tail`() {
|
||||
val original = setBufferPayload(
|
||||
type = 0,
|
||||
sectionPayload = byteArrayOf(1, 2, 3),
|
||||
)
|
||||
|
||||
val rewritten = AxiomSetBufferPayloadCodec.rewriteBlockSections(original) { reader, writer ->
|
||||
assertContentEquals(byteArrayOf(1, 2, 3), reader.readByteArray())
|
||||
writer.writeByteArray(byteArrayOf(9, 8, 7, 6))
|
||||
}
|
||||
|
||||
val reader = AxiomByteReader(rewritten)
|
||||
assertEquals("minecraft:overworld", reader.readUtf())
|
||||
assertEquals(0x0102030405060708L, reader.readLong())
|
||||
assertEquals(0x1112131415161718L, reader.readLong())
|
||||
assertEquals(0, reader.readUnsignedByte())
|
||||
assertEquals(0x2122232425262728L, reader.readLong())
|
||||
assertContentEquals(byteArrayOf(9, 8, 7, 6), reader.readByteArray())
|
||||
assertBlockEntityCopied(reader)
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertEquals(64, reader.readVarInt())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passes non block buffer payloads through after header`() {
|
||||
val original = setBufferPayload(
|
||||
type = 1,
|
||||
sectionPayload = byteArrayOf(1, 2, 3),
|
||||
)
|
||||
|
||||
val rewritten = AxiomSetBufferPayloadCodec.rewriteBlockSections(original) { _, _ ->
|
||||
error("Biome buffers should not invoke block section rewriter")
|
||||
}
|
||||
|
||||
assertContentEquals(original, rewritten)
|
||||
}
|
||||
|
||||
private fun setBufferPayload(type: Int, sectionPayload: ByteArray): ByteArray {
|
||||
val writer = AxiomByteWriter()
|
||||
.writeUtf("minecraft:overworld")
|
||||
.writeLong(0x0102030405060708L)
|
||||
.writeLong(0x1112131415161718L)
|
||||
.writeByte(type)
|
||||
|
||||
if (type == 0) {
|
||||
writer
|
||||
.writeLong(0x2122232425262728L)
|
||||
.writeByteArray(sectionPayload)
|
||||
writeBlockEntity(writer)
|
||||
writer
|
||||
.writeLong(AxiomProtocol.MIN_POSITION_LONG)
|
||||
.writeVarInt(64)
|
||||
} else {
|
||||
writer
|
||||
.writeByteArray(sectionPayload)
|
||||
.writeVarInt(64)
|
||||
}
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private fun writeBlockEntity(writer: AxiomByteWriter) {
|
||||
writer
|
||||
.writeVarInt(1)
|
||||
.writeShort(0x00AF)
|
||||
.writeVarInt(1234)
|
||||
.writeByte(0)
|
||||
.writeByteArray(byteArrayOf(5, 4, 3, 2, 1))
|
||||
}
|
||||
|
||||
private fun assertBlockEntityCopied(reader: AxiomByteReader) {
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(0x00AF, reader.readShort().toInt())
|
||||
assertEquals(1234, reader.readVarInt())
|
||||
assertEquals(0, reader.readUnsignedByte())
|
||||
assertContentEquals(byteArrayOf(5, 4, 3, 2, 1), reader.readByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomTunnelCodecTest {
|
||||
@Test fun `uncompressed single fragment matches upstream wire format`() {
|
||||
val fragment = AxiomByteWriter().writeByte(3).writeUtf("axiom:set_block").writeInt(3)
|
||||
.writeByte(0).writeBytes(byteArrayOf(5, 6, 7)).toByteArray()
|
||||
val packet = AxiomTunnelCodec.decode(listOf(fragment))
|
||||
assertEquals("axiom:set_block", packet.channel)
|
||||
assertContentEquals(byteArrayOf(5, 6, 7), packet.payload)
|
||||
assertFalse(packet.compressed)
|
||||
assertContentEquals(fragment, AxiomTunnelCodec.encode(packet).single())
|
||||
}
|
||||
|
||||
@Test fun `fragmented packets obey total fragment size and recover exact payload`() {
|
||||
val packet = AxiomTunnelPacket("axiom:set_buffer", ByteArray(257) { it.toByte() })
|
||||
val fragments = AxiomTunnelCodec.encode(packet, splitSize = 23)
|
||||
assertTrue(fragments.size > 2)
|
||||
assertTrue(fragments.all { it.size <= 23 })
|
||||
assertEquals(1, fragments.first()[0].toInt())
|
||||
assertEquals(2, fragments.last()[0].toInt())
|
||||
assertContentEquals(packet.payload, AxiomTunnelCodec.decode(fragments, 23).payload)
|
||||
}
|
||||
|
||||
@Test fun `actual Zstd compression survives split transport`() {
|
||||
val packet = AxiomTunnelPacket("axiom:set_buffer", ByteArray(16384) { (it * 17).toByte() }, true)
|
||||
val fragments = AxiomTunnelCodec.encode(packet, 71)
|
||||
assertTrue(fragments.size > 1)
|
||||
val decoded = AxiomTunnelCodec.decode(fragments, 71)
|
||||
assertTrue(decoded.compressed)
|
||||
assertContentEquals(packet.payload, decoded.payload)
|
||||
}
|
||||
|
||||
@Test fun `rejects out of order fragments and oversized decoded payload`() {
|
||||
val packet = AxiomTunnelPacket("axiom:set_buffer", ByteArray(100), true)
|
||||
val fragments = AxiomTunnelCodec.encode(packet, 10)
|
||||
assertFailsWith<IllegalArgumentException> { AxiomTunnelCodec.decode(fragments.reversed(), 10) }
|
||||
assertFailsWith<IllegalArgumentException> { AxiomTunnelCodec.decode(fragments, 10, 99) }
|
||||
assertFailsWith<IllegalArgumentException> { AxiomTunnelCodec.encode(packet, 10, 99) }
|
||||
}
|
||||
|
||||
@Test fun `rejects mismatched declared length corrupt compression and unknown flags`() {
|
||||
for ((length, flags) in listOf(100 to 0, 3 to 1, 3 to 2, -1 to 0)) {
|
||||
val fragment = AxiomByteWriter().writeByte(3).writeUtf("axiom:set_block").writeInt(length)
|
||||
.writeByte(flags).writeBytes(byteArrayOf(5, 6, 7)).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { AxiomTunnelCodec.decode(listOf(fragment)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package me.wargearsystems.viaaxiom.protocol
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import kotlin.test.*
|
||||
|
||||
class PalettedContainerCodecTest {
|
||||
@Test fun `singleton consumes exactly one section across both formats`() {
|
||||
for (protocol in listOf(769, 770)) {
|
||||
val bytes = AxiomByteWriter().writeByte(0).writeVarInt(1234).also {
|
||||
if (protocol < 770) it.writeVarInt(0)
|
||||
}.writeInt(0x12345678).toByteArray()
|
||||
val reader = AxiomByteReader(bytes)
|
||||
val container = PalettedContainerCodec.readBlockContainer(reader, protocol, 15)
|
||||
assertEquals(1234, container.idAt(4095))
|
||||
assertEquals(0x12345678, reader.readInt())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `local palette conversion preserves indexes and merges colliding states`() {
|
||||
val input = AxiomByteWriter().writeByte(4).writeVarInt(2).writeVarInt(2).writeVarInt(3)
|
||||
.writeVarInt(256).writeLongArray(LongArray(256) { 0x1010101010101010L }, false).toByteArray()
|
||||
val output = AxiomByteWriter()
|
||||
PalettedContainerCodec.rewriteBlockStateIds(AxiomByteReader(input), output, 769, 15, { 700 }, 770, 16)
|
||||
assertContentEquals(AxiomByteWriter().writeByte(0).writeVarInt(700).toByteArray(), output.toByteArray())
|
||||
}
|
||||
|
||||
@Test fun `global wire IDs become indexes and sparse registry IDs survive mapping`() {
|
||||
// Four 15-bit entries per long, with one slot unused at the top.
|
||||
val packed = 1000L or (2000L shl 15) or (1000L shl 30) or (2000L shl 45)
|
||||
val input = AxiomByteWriter().writeByte(15).writeLongArray(LongArray(1024) { packed }, false)
|
||||
.writeByte(42).toByteArray()
|
||||
val reader = AxiomByteReader(input)
|
||||
val container = PalettedContainerCodec.readBlockContainer(reader, 770, 15)
|
||||
assertEquals(1000, container.idAt(0))
|
||||
assertEquals(2000, container.idAt(4095))
|
||||
assertEquals(42, reader.readUnsignedByte())
|
||||
container.replaceIds { it + 1 }
|
||||
assertEquals(1001, container.idAt(0))
|
||||
assertEquals(2001, container.idAt(4095))
|
||||
}
|
||||
|
||||
@Test fun `global palette writes every value across partial final long`() {
|
||||
val data = PalettedContainerData((1000..1299).toMutableList(), IntArray(4096) { it % 300 })
|
||||
for (protocol in listOf(769, 770)) {
|
||||
val writer = AxiomByteWriter()
|
||||
PalettedContainerCodec.writeBlockContainer(writer, data, protocol, 13)
|
||||
val bytes = writer.toByteArray()
|
||||
assertEquals(13, bytes[0].toInt())
|
||||
val restored = PalettedContainerCodec.readBlockContainer(AxiomByteReader(bytes), protocol, 13)
|
||||
repeat(4096) { assertEquals(1000 + it % 300, restored.idAt(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `same layout rewrite decodes like the full conversion`() {
|
||||
val random = kotlin.random.Random(7)
|
||||
val mapper = { id: Int -> (id * 3 + 1) % 500 }
|
||||
for (protocol in listOf(769, 770)) {
|
||||
for (distinct in listOf(1, 2, 16, 17, 256, 300)) {
|
||||
for (width in listOf(9, 13, 15)) {
|
||||
val ids = List(distinct) { random.nextInt(500) }.distinct()
|
||||
val source = PalettedContainerData(ids.toMutableList(), IntArray(4096) { random.nextInt(ids.size) })
|
||||
val input = AxiomByteWriter().also { PalettedContainerCodec.writeBlockContainer(it, source, protocol, width) }
|
||||
.writeByte(42).toByteArray()
|
||||
val output = AxiomByteWriter()
|
||||
val reader = AxiomByteReader(input)
|
||||
PalettedContainerCodec.rewriteBlockStateIds(reader, output, protocol, width, mapper)
|
||||
assertEquals(42, reader.readUnsignedByte())
|
||||
|
||||
val result = AxiomByteReader(output.toByteArray())
|
||||
val actual = PalettedContainerCodec.readBlockContainer(result, protocol, width)
|
||||
assertEquals(0, result.readableBytes)
|
||||
repeat(4096) { assertEquals(mapper(source.idAt(it)), actual.idAt(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `palette indexes are copied without changing the packed data`() {
|
||||
val packed = LongArray(256) { 0x0123012301230123L }
|
||||
val input = AxiomByteWriter().writeByte(4).writeVarInt(4).writeVarInt(5).writeVarInt(6).writeVarInt(7).writeVarInt(8)
|
||||
.writeLongArray(packed, false).toByteArray()
|
||||
val output = AxiomByteWriter()
|
||||
PalettedContainerCodec.rewriteBlockStateIds(AxiomByteReader(input), output, 770, 15, { 700 })
|
||||
val expected = AxiomByteWriter().writeByte(4).writeVarInt(4).writeVarInt(700).writeVarInt(700).writeVarInt(700).writeVarInt(700)
|
||||
.writeLongArray(packed, false).toByteArray()
|
||||
assertContentEquals(expected, output.toByteArray())
|
||||
}
|
||||
|
||||
@Test fun `same layout rewrite rejects bad indexes and IDs that do not fit`() {
|
||||
val badIndex = AxiomByteWriter().writeByte(4).writeVarInt(1).writeVarInt(1)
|
||||
.writeLongArray(LongArray(256) { 2L }, false).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
PalettedContainerCodec.rewriteBlockStateIds(AxiomByteReader(badIndex), AxiomByteWriter(), 770, 15, { it })
|
||||
}
|
||||
val singleton = AxiomByteWriter().writeByte(0).writeVarInt(1).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
PalettedContainerCodec.rewriteBlockStateIds(AxiomByteReader(singleton), AxiomByteWriter(), 770, 15, { 32768 })
|
||||
}
|
||||
val global = AxiomByteWriter().writeByte(15).writeLongArray(LongArray(1024), false).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
PalettedContainerCodec.rewriteBlockStateIds(AxiomByteReader(global), AxiomByteWriter(), 770, 15, { -1 })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `rejects invalid palette indexes and mismatched array sizes`() {
|
||||
val invalidIndex = AxiomByteWriter().writeByte(4).writeVarInt(1).writeVarInt(1)
|
||||
.writeLongArray(LongArray(256) { 2L }, false).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { PalettedContainerCodec.readBlockContainer(AxiomByteReader(invalidIndex), 770, 15) }
|
||||
val invalidLength = AxiomByteWriter().writeByte(0).writeVarInt(1).writeVarInt(1).writeLong(0).toByteArray()
|
||||
assertFailsWith<IllegalArgumentException> { PalettedContainerCodec.readBlockContainer(AxiomByteReader(invalidLength), 769, 15) }
|
||||
}
|
||||
|
||||
@Test fun `target width overflow is rejected instead of truncating block IDs`() {
|
||||
val data = PalettedContainerData(mutableListOf(32768), IntArray(4096))
|
||||
assertFailsWith<IllegalArgumentException> { PalettedContainerCodec.writeBlockContainer(AxiomByteWriter(), data, 770, 15) }
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.protocol.*
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomBulkTranslationTest {
|
||||
private val engine = AxiomTranslationEngine(object : BlockStateIdTranslator {
|
||||
override fun clientToServer(blockStateId: Int) = blockStateId + 1000
|
||||
override fun serverToClient(blockStateId: Int) = blockStateId - 1000
|
||||
}, BlockStatePaletteWidths(15, 16))
|
||||
private val session = AxiomTranslationSession(AxiomHello(9, 1, 769), 770)
|
||||
|
||||
@Test fun `set buffer rewrites multiple real palettes and preserves compressed block entities`() {
|
||||
val writer = AxiomByteWriter().writeUtf("minecraft:overworld").writeLong(10).writeLong(20).writeByte(0)
|
||||
for ((section, state) in listOf(100L to 2, 200L to 3)) {
|
||||
writer.writeLong(section).writeByte(0).writeVarInt(state).writeVarInt(0)
|
||||
writer.writeVarInt(1).writeShort(42).writeVarInt(7).writeByte(0).writeByteArray(byteArrayOf(9, 8, 7))
|
||||
}
|
||||
writer.writeLong(AxiomProtocol.MIN_POSITION_LONG).writeVarInt(64)
|
||||
val result = engine.rewrite(AxiomPayload(AxiomPayloadDirection.SERVERBOUND, "axiom:set_buffer", writer.toByteArray()), session)
|
||||
val reader = AxiomByteReader(result)
|
||||
assertEquals("minecraft:overworld", reader.readUtf())
|
||||
assertEquals(10, reader.readLong())
|
||||
assertEquals(20, reader.readLong())
|
||||
assertEquals(0, reader.readUnsignedByte())
|
||||
for ((section, state) in listOf(100L to 1002, 200L to 1003)) {
|
||||
assertEquals(section, reader.readLong())
|
||||
assertEquals(state, PalettedContainerCodec.readBlockContainer(reader, 770, 16).idAt(4095))
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(42, reader.readShort().toInt())
|
||||
assertEquals(7, reader.readVarInt())
|
||||
assertEquals(0, reader.readUnsignedByte())
|
||||
assertContentEquals(byteArrayOf(9, 8, 7), reader.readByteArray())
|
||||
}
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertEquals(64, reader.readVarInt())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test fun `response maps server palette to legacy client format and retains missing sections`() {
|
||||
val bytes = AxiomByteWriter().writeLong(77).writeLong(AxiomProtocol.MIN_POSITION_LONG)
|
||||
.writeLong(100).writeBoolean(true).writeByte(0).writeVarInt(1002)
|
||||
.writeLong(200).writeBoolean(false).writeLong(AxiomProtocol.MIN_POSITION_LONG)
|
||||
.writeBoolean(true).toByteArray()
|
||||
val result = engine.rewrite(AxiomPayload(AxiomPayloadDirection.CLIENTBOUND, "axiom:response_chunk_data", bytes), session)
|
||||
val reader = AxiomByteReader(result)
|
||||
assertEquals(77, reader.readLong())
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertEquals(100, reader.readLong())
|
||||
assertTrue(reader.readBoolean())
|
||||
assertEquals(2, PalettedContainerCodec.readBlockContainer(reader, 769, 15).idAt(0))
|
||||
assertEquals(200, reader.readLong())
|
||||
assertFalse(reader.readBoolean())
|
||||
assertEquals(AxiomProtocol.MIN_POSITION_LONG, reader.readLong())
|
||||
assertTrue(reader.readBoolean())
|
||||
assertEquals(0, reader.readableBytes)
|
||||
}
|
||||
|
||||
@Test fun `biome buffer passes through without touching its body`() {
|
||||
val bytes = AxiomByteWriter().writeUtf("minecraft:overworld").writeLong(10).writeLong(20)
|
||||
.writeByte(1).writeBytes(byteArrayOf(1, 2, 3)).toByteArray()
|
||||
assertContentEquals(bytes, engine.rewrite(AxiomPayload(AxiomPayloadDirection.SERVERBOUND, "axiom:set_buffer", bytes), session))
|
||||
}
|
||||
|
||||
@Test fun `bulk rewriting requires explicit registry widths`() {
|
||||
val bytes = byteArrayOf(1, 2, 3)
|
||||
assertContentEquals(bytes, AxiomTranslationEngine(IdentityBlockStateIdTranslator).rewrite(
|
||||
AxiomPayload(AxiomPayloadDirection.SERVERBOUND, "axiom:set_buffer", bytes), session,
|
||||
))
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomCustomPayloadFrameCodec
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomHello
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadDirection
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomProtocol
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTranslationSession
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomFrameTranslationEngineTest {
|
||||
private val frameEngine = AxiomFrameTranslationEngine(
|
||||
AxiomTranslationEngine(
|
||||
TableBlockStateIdTranslator(
|
||||
clientToServer = intArrayOf(0, 101, 102),
|
||||
serverToClient = intArrayOf(0, 201, 202),
|
||||
fallbackId = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `rewrites set block payload inside a matching custom payload frame`() {
|
||||
val rewrittenFrame = frameEngine.rewrite(
|
||||
frameBytes = customPayloadFrame(packetId = 12, channel = AxiomProtocol.channel("set_block"), payload = setBlockPayload(2)),
|
||||
customPayloadPacketId = 12,
|
||||
direction = AxiomPayloadDirection.SERVERBOUND,
|
||||
session = session(clientProtocol = 772, serverProtocol = 773),
|
||||
)
|
||||
|
||||
val frame = AxiomCustomPayloadFrameCodec.readIfCustomPayload(rewrittenFrame, customPayloadPacketId = 12)
|
||||
requireNotNull(frame)
|
||||
val payload = AxiomByteReader(frame.payloadBytes)
|
||||
|
||||
assertEquals(1, payload.readVarInt())
|
||||
assertEquals(0x0102030405060708L, payload.readLong())
|
||||
assertEquals(102, payload.readVarInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passes through when the packet id is not custom payload`() {
|
||||
val frame = customPayloadFrame(packetId = 11, channel = AxiomProtocol.channel("set_block"), payload = setBlockPayload(2))
|
||||
|
||||
val rewrittenFrame = frameEngine.rewrite(
|
||||
frameBytes = frame,
|
||||
customPayloadPacketId = 12,
|
||||
direction = AxiomPayloadDirection.SERVERBOUND,
|
||||
session = session(clientProtocol = 772, serverProtocol = 773),
|
||||
)
|
||||
|
||||
assertContentEquals(frame, rewrittenFrame)
|
||||
}
|
||||
|
||||
private fun customPayloadFrame(packetId: Int, channel: String, payload: ByteArray): ByteArray {
|
||||
return AxiomByteWriter()
|
||||
.writeVarInt(packetId)
|
||||
.writeUtf(channel)
|
||||
.writeBytes(payload)
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
private fun setBlockPayload(blockStateId: Int): ByteArray {
|
||||
return AxiomByteWriter()
|
||||
.writeVarInt(1)
|
||||
.writeLong(0x0102030405060708L)
|
||||
.writeVarInt(blockStateId)
|
||||
.writeBoolean(false)
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
private fun session(clientProtocol: Int, serverProtocol: Int): AxiomTranslationSession {
|
||||
return AxiomTranslationSession(
|
||||
hello = AxiomHello(
|
||||
apiVersion = AxiomProtocol.API_VERSION,
|
||||
dataVersion = 4438,
|
||||
protocolVersion = clientProtocol,
|
||||
),
|
||||
serverProtocolVersion = serverProtocol,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomHello
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomPayloadDirection
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomProtocol
|
||||
import me.wargearsystems.viaaxiom.protocol.AxiomTranslationSession
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AxiomTranslationEngineTest {
|
||||
private val translator = TableBlockStateIdTranslator(
|
||||
clientToServer = intArrayOf(0, 101, 102, 103),
|
||||
serverToClient = intArrayOf(0, 201, 202, 203),
|
||||
fallbackId = 0,
|
||||
)
|
||||
private val engine = AxiomTranslationEngine(translator)
|
||||
|
||||
@Test
|
||||
fun `passes through when protocol versions match`() {
|
||||
val payload = setBlockPayload(blockStateId = 2)
|
||||
val rewritten = engine.rewrite(
|
||||
payload = AxiomPayload(AxiomPayloadDirection.SERVERBOUND, AxiomProtocol.channel("set_block"), payload),
|
||||
session = session(clientProtocol = 773, serverProtocol = 773),
|
||||
)
|
||||
|
||||
assertContentEquals(payload, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passes through non axiom channels`() {
|
||||
val payload = byteArrayOf(1, 2, 3)
|
||||
val rewritten = engine.rewrite(
|
||||
payload = AxiomPayload(AxiomPayloadDirection.SERVERBOUND, "minecraft:brand", payload),
|
||||
session = session(clientProtocol = 772, serverProtocol = 773),
|
||||
)
|
||||
|
||||
assertContentEquals(payload, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites serverbound set block ids on protocol mismatch`() {
|
||||
val rewritten = engine.rewrite(
|
||||
payload = AxiomPayload(
|
||||
direction = AxiomPayloadDirection.SERVERBOUND,
|
||||
channel = AxiomProtocol.channel("set_block"),
|
||||
bytes = setBlockPayload(blockStateId = 2),
|
||||
),
|
||||
session = session(clientProtocol = 772, serverProtocol = 773),
|
||||
)
|
||||
|
||||
val reader = AxiomByteReader(rewritten)
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(0x0102030405060708L, reader.readLong())
|
||||
assertEquals(102, reader.readVarInt())
|
||||
}
|
||||
|
||||
private fun setBlockPayload(blockStateId: Int): ByteArray {
|
||||
return AxiomByteWriter()
|
||||
.writeVarInt(1)
|
||||
.writeLong(0x0102030405060708L)
|
||||
.writeVarInt(blockStateId)
|
||||
.writeBoolean(false)
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
private fun session(clientProtocol: Int, serverProtocol: Int): AxiomTranslationSession {
|
||||
return AxiomTranslationSession(
|
||||
hello = AxiomHello(
|
||||
apiVersion = AxiomProtocol.API_VERSION,
|
||||
dataVersion = 4438,
|
||||
protocolVersion = clientProtocol,
|
||||
),
|
||||
serverProtocolVersion = serverProtocol,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteReader
|
||||
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
|
||||
import me.wargearsystems.viaaxiom.protocol.*
|
||||
import kotlin.test.*
|
||||
|
||||
class AxiomTunnelTranslationEngineTest {
|
||||
private val enable = AxiomEnable(true, 0, 2, 0, 0, 0, 17, 1024, listOf("axiom:set_block"))
|
||||
private val session = AxiomTranslationSession(AxiomHello(10, 5000, 775, 42), 773, enable = enable)
|
||||
private val translator = AxiomTranslationEngine(object : BlockStateIdTranslator {
|
||||
override fun clientToServer(blockStateId: Int) = 100_000 + blockStateId
|
||||
override fun serverToClient(blockStateId: Int) = blockStateId
|
||||
})
|
||||
|
||||
@Test fun `fragmented compressed upload translates inner IDs and preserves tail`() {
|
||||
val payload = AxiomByteWriter().writeVarInt(1).writeLong(123).writeVarInt(2)
|
||||
.writeBytes(byteArrayOf(0, 9, 8, 7)).toByteArray()
|
||||
for (compressed in listOf(false, true)) {
|
||||
val fragments = AxiomTunnelCodec.encode(AxiomTunnelPacket("axiom:set_block", payload, compressed), 17, 1024)
|
||||
val result = AxiomTunnelTranslationEngine(translator).rewrite(fragments, session)
|
||||
assertTrue(result.all { it.size <= 17 })
|
||||
val packet = AxiomTunnelCodec.decode(result, 17, 1024)
|
||||
assertEquals(compressed, packet.compressed)
|
||||
val reader = AxiomByteReader(packet.payload)
|
||||
assertEquals(1, reader.readVarInt())
|
||||
assertEquals(123, reader.readLong())
|
||||
assertEquals(100_002, reader.readVarInt())
|
||||
assertContentEquals(byteArrayOf(0, 9, 8, 7), reader.copyRemaining())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `unchanged payload retains original compression and fragment bytes`() {
|
||||
val payload = AxiomByteWriter().writeVarInt(0).writeBoolean(false).toByteArray()
|
||||
val fragments = AxiomTunnelCodec.encode(AxiomTunnelPacket("axiom:set_block", payload, true), 17, 1024)
|
||||
assertSame(fragments, AxiomTunnelTranslationEngine(translator).rewrite(fragments, session))
|
||||
}
|
||||
|
||||
@Test fun `refuses channels not advertised by server`() {
|
||||
val fragments = AxiomTunnelCodec.encode(AxiomTunnelPacket("axiom:delete_entity", byteArrayOf()), 17, 1024)
|
||||
assertFailsWith<IllegalArgumentException> { AxiomTunnelTranslationEngine(translator).rewrite(fragments, session) }
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BlockStateIdTranslatorTest {
|
||||
@Test
|
||||
fun `uses directional placeholders for missing block state ids`() {
|
||||
val translator = TableBlockStateIdTranslator(
|
||||
clientToServer = intArrayOf(0, 10, -1),
|
||||
serverToClient = intArrayOf(0, -1, 20),
|
||||
missingBlockStatePolicy = MissingBlockStatePolicy(
|
||||
clientToServerFallbackId = 5,
|
||||
serverToClientFallbackId = 6,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(10, translator.clientToServer(1))
|
||||
assertEquals(5, translator.clientToServer(2))
|
||||
assertEquals(5, translator.clientToServer(99))
|
||||
|
||||
assertEquals(20, translator.serverToClient(2))
|
||||
assertEquals(6, translator.serverToClient(1))
|
||||
assertEquals(6, translator.serverToClient(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single placeholder constructor applies in both directions`() {
|
||||
val translator = TableBlockStateIdTranslator(
|
||||
clientToServer = intArrayOf(-1),
|
||||
serverToClient = intArrayOf(-1),
|
||||
fallbackId = 7,
|
||||
)
|
||||
|
||||
assertEquals(7, translator.clientToServer(0))
|
||||
assertEquals(7, translator.serverToClient(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports missing block state fallback usage`() {
|
||||
val reports = mutableListOf<Triple<BlockStateMappingDirection, Int, Int>>()
|
||||
val translator = TableBlockStateIdTranslator(
|
||||
clientToServer = intArrayOf(-1),
|
||||
serverToClient = intArrayOf(-1),
|
||||
missingBlockStatePolicy = MissingBlockStatePolicy(
|
||||
clientToServerFallbackId = 5,
|
||||
serverToClientFallbackId = 6,
|
||||
),
|
||||
onMissingBlockState = { direction, missingId, fallbackId ->
|
||||
reports += Triple(direction, missingId, fallbackId)
|
||||
},
|
||||
)
|
||||
|
||||
translator.clientToServer(0)
|
||||
translator.serverToClient(99)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Triple(BlockStateMappingDirection.CLIENT_TO_SERVER, 0, 5),
|
||||
Triple(BlockStateMappingDirection.SERVER_TO_CLIENT, 99, 6),
|
||||
),
|
||||
reports,
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package me.wargearsystems.viaaxiom.translation
|
||||
|
||||
import kotlin.test.*
|
||||
|
||||
class BlockStateMappingChainTest {
|
||||
@Test fun `multi version mappings compose in server to client order`() {
|
||||
val (outbound, inbound) = BlockStateMappingChain.compose(listOf(intArrayOf(0, 2, 1), intArrayOf(0, 1, 3)), 5, 1)
|
||||
assertContentEquals(intArrayOf(0, 3, 1), inbound)
|
||||
assertContentEquals(intArrayOf(0, 2, -1, 1, -1), outbound)
|
||||
}
|
||||
|
||||
@Test fun `many to one collision prefers earlier canonical server state`() {
|
||||
val (outbound, _) = BlockStateMappingChain.compose(listOf(intArrayOf(0, 1, 1, 2)), 3, 1)
|
||||
assertEquals(1, outbound[1])
|
||||
}
|
||||
|
||||
@Test fun `missing mappings remain missing and stone must exist`() {
|
||||
val (_, inbound) = BlockStateMappingChain.compose(listOf(intArrayOf(0, 1, -1, 99)), 3, 1)
|
||||
assertContentEquals(intArrayOf(0, 1, -1, -1), inbound)
|
||||
assertFailsWith<IllegalArgumentException> { BlockStateMappingChain.compose(listOf(intArrayOf(0, -1)), 3, 1) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user