Add ViaAxiom
Deploy / Build (push) Skipped
Pull Request Build / Build (pull_request) Failing after 17s

This commit is contained in:
Jakob Schulz
2026-11-01 15:30:21 +01:00
parent e198772885
commit f373e3d6d9
76 changed files with 4496 additions and 896 deletions
@@ -0,0 +1,163 @@
package me.wargearsystems.viaaxiom.velocity
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.util.ReferenceCountUtil
import me.wargearsystems.viaaxiom.binary.AxiomByteWriter
import me.wargearsystems.viaaxiom.protocol.AxiomCustomPayloadFrame
import me.wargearsystems.viaaxiom.protocol.AxiomProtocol
import me.wargearsystems.viaaxiom.protocol.AxiomProxySession
import org.slf4j.Logger
import java.util.concurrent.TimeUnit
/** Outlives server switches. Client and backend channels share the player's event loop. */
internal class PlayerAxiomState {
@Volatile
var session: AxiomProxySession? = null
}
/** Drops frames it cannot translate, their IDs would select the wrong blocks. */
internal class AxiomServerboundHandler(
private val state: PlayerAxiomState,
private val messages: PluginMessages,
private val logger: Logger,
) : ChannelInboundHandlerAdapter() {
override fun channelRead(context: ChannelHandlerContext, packet: Any) {
val session = state.session
if (session == null || !messages.isPluginMessage(packet) || !AxiomProtocol.isAxiomChannel(messages.channel(packet))) {
context.fireChannelRead(packet)
return
}
val frame = AxiomCustomPayloadFrame(0, messages.channel(packet), messages.bytes(packet))
ReferenceCountUtil.release(packet)
val output = try {
session.serverbound(frame)
} catch (failure: RuntimeException) {
logger.warn("Dropped serverbound {}: {}", frame.channel, failure.message)
return
}
output.forEach { context.fireChannelRead(messages.create(it.channel, it.payloadBytes)) }
}
}
/**
* Added when the backend channel is created. Packets decoded together with JoinGame pass before
* ServerConnectedEvent runs, so registrations are recorded and Axiom packets held until [attach].
*/
internal class AxiomClientboundHandler(
private val messages: PluginMessages,
private val logger: Logger,
) : ChannelInboundHandlerAdapter() {
private class Attachment(
val session: AxiomProxySession,
val state: PlayerAxiomState,
val sendToClient: (channel: String, bytes: ByteArray) -> Unit,
)
private lateinit var context: ChannelHandlerContext
private var waiting = true
private var attachment: Attachment? = null
private val registrations = mutableListOf<ByteArray>()
private val held = mutableListOf<Any>()
private var legacyHandshakeScheduled = false
override fun handlerAdded(context: ChannelHandlerContext) {
this.context = context
}
fun attach(session: AxiomProxySession, state: PlayerAxiomState, sendToClient: (String, ByteArray) -> Unit) {
context.executor().execute {
attachment = Attachment(session, state, sendToClient)
release()
}
}
fun detach() {
context.executor().execute(::release)
}
private fun release() {
waiting = false
registrations.forEach(::registered)
registrations.clear()
val packets = held.toList()
held.clear()
packets.forEach { channelRead(context, it) }
}
override fun channelRead(context: ChannelHandlerContext, packet: Any) {
if (!messages.isPluginMessage(packet)) {
context.fireChannelRead(packet)
return
}
val channel = messages.channel(packet)
if (channel == "minecraft:register") {
if (waiting) registrations += messages.bytes(packet) else registered(messages.bytes(packet))
context.fireChannelRead(packet)
} else if (!AxiomProtocol.isAxiomChannel(channel)) {
context.fireChannelRead(packet)
} else if (waiting && held.size < MAX_HELD_PACKETS) {
held += packet
} else {
translate(context, channel, packet)
}
}
private fun translate(context: ChannelHandlerContext, channel: String, packet: Any) {
val attachment = attachment
if (attachment == null) {
context.fireChannelRead(packet)
return
}
val frame = AxiomCustomPayloadFrame(0, channel, messages.bytes(packet))
ReferenceCountUtil.release(packet)
val output = try {
attachment.session.clientbound(frame)
} catch (failure: RuntimeException) {
logger.warn("Dropped clientbound {}: {}", channel, failure.message)
if (channel == "axiom:enable") {
attachment.sendToClient("axiom:goodbye", AxiomByteWriter().writeUtf("ViaAxiom: ${failure.message}").toByteArray())
}
return
}
context.fireChannelRead(messages.create(output.channel, output.payloadBytes))
}
private fun registered(bytes: ByteArray) {
val attachment = attachment ?: return
val session = attachment.session
try {
session.backendRegistered(bytes)
} catch (failure: IllegalArgumentException) {
logger.warn("Ignored backend channel registration: {}", failure.message)
return
}
if (legacyHandshakeScheduled || !session.awaitingLegacyChallenge) return
legacyHandshakeScheduled = true
// Backends may register across several packets, and axiom:tunnel marks them as API 10.
context.executor().schedule({
if (context.channel().isActive && attachment.state.session === session) {
session.startLegacyHandshake()?.let { attachment.sendToClient(it.channel, it.payloadBytes) }
}
}, LEGACY_HANDSHAKE_DELAY_MILLIS, TimeUnit.MILLISECONDS)
}
override fun channelInactive(context: ChannelHandlerContext) {
releaseHeld()
super.channelInactive(context)
}
override fun handlerRemoved(context: ChannelHandlerContext) {
releaseHeld()
}
private fun releaseHeld() {
held.forEach(ReferenceCountUtil::release)
held.clear()
}
private companion object {
const val LEGACY_HANDSHAKE_DELAY_MILLIS = 500L
const val MAX_HELD_PACKETS = 256
}
}
@@ -0,0 +1,69 @@
package me.wargearsystems.viaaxiom.velocity
import com.velocitypowered.api.proxy.Player
import com.velocitypowered.api.proxy.ProxyServer
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufHolder
import io.netty.buffer.ByteBufUtil
import io.netty.buffer.Unpooled
import io.netty.channel.Channel
import io.netty.channel.ChannelInitializer
/** Velocity's API exposes neither channels nor packets, and its proxy module is not published. */
internal object VelocityConnections {
const val MINECRAFT_DECODER = "minecraft-decoder"
fun clientChannel(player: Player): Channel = channelOf(call(player, "getConnection"))
/** The backend named by a ServerConnectedEvent stays in flight until the event completes. */
fun incomingBackendChannel(player: Player): Channel {
val connection = requireNotNull(call(player, "getConnectionInFlight")) { "No backend connection in flight" }
return channelOf(requireNotNull(call(connection, "getConnection")) { "Backend connection closed" })
}
fun onBackendChannel(proxy: ProxyServer, configure: (Channel) -> Unit) {
val connectionManager = proxy.javaClass.getDeclaredField("cm").apply { isAccessible = true }.get(proxy)
val holder = requireNotNull(call(connectionManager, "getBackendChannelInitializer"))
@Suppress("UNCHECKED_CAST")
val original = requireNotNull(call(holder, "get")) as ChannelInitializer<Channel>
holder.javaClass.getMethod("set", ChannelInitializer::class.java).invoke(holder, WrappingInitializer(original, configure))
}
private class WrappingInitializer(
private val original: ChannelInitializer<Channel>,
private val configure: (Channel) -> Unit,
) : ChannelInitializer<Channel>() {
override fun initChannel(channel: Channel) {
INIT_CHANNEL.invoke(original, channel)
configure(channel)
}
// Velocity reuses one initializer for every backend connection.
override fun isSharable() = true
}
private val INIT_CHANNEL = ChannelInitializer::class.java.getDeclaredMethod("initChannel", Channel::class.java)
.apply { isAccessible = true }
private fun channelOf(connection: Any?): Channel = call(requireNotNull(connection), "getChannel") as Channel
private fun call(receiver: Any, method: String): Any? = receiver.javaClass.getMethod(method).invoke(receiver)
}
internal interface PluginMessages {
fun isPluginMessage(packet: Any): Boolean
fun channel(packet: Any): String
fun bytes(packet: Any): ByteArray
fun create(channel: String, bytes: ByteArray): Any
}
internal class VelocityPluginMessages : PluginMessages {
private val type = Class.forName("com.velocitypowered.proxy.protocol.packet.PluginMessagePacket")
private val getChannel = type.getMethod("getChannel")
private val constructor = type.getConstructor(String::class.java, ByteBuf::class.java)
override fun isPluginMessage(packet: Any) = type.isInstance(packet)
override fun channel(packet: Any) = getChannel.invoke(packet) as String
override fun bytes(packet: Any): ByteArray = ByteBufUtil.getBytes((packet as ByteBufHolder).content())
override fun create(channel: String, bytes: ByteArray): Any = constructor.newInstance(channel, Unpooled.wrappedBuffer(bytes))
}
@@ -0,0 +1,77 @@
package me.wargearsystems.viaaxiom.velocity
import com.google.inject.Inject
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.player.ServerConnectedEvent
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.proxy.Player
import com.velocitypowered.api.proxy.ProxyServer
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier
import com.viaversion.viaversion.api.Via
import io.netty.channel.Channel
import io.netty.util.AttributeKey
import me.wargearsystems.viaaxiom.protocol.AxiomProxySession
import org.slf4j.Logger
class ViaAxiomPlugin @Inject constructor(private val proxy: ProxyServer, private val logger: Logger) {
private val mappings = ViaBlockMappings(logger)
private val messages = VelocityPluginMessages()
@Subscribe
fun onProxyInitialize(event: ProxyInitializeEvent) {
VelocityConnections.onBackendChannel(proxy) { channel ->
channel.pipeline().addAfter(VelocityConnections.MINECRAFT_DECODER, HANDLER_NAME, AxiomClientboundHandler(messages, logger))
}
}
/** Not on the event loop, so building mappings cannot stall players. */
@Subscribe
fun onServerConnected(event: ServerConnectedEvent) {
val player = event.player
val serverName = event.server.serverInfo.name
var backend: AxiomClientboundHandler? = null
try {
backend = VelocityConnections.incomingBackendChannel(player).pipeline().get(HANDLER_NAME) as AxiomClientboundHandler
val clientChannel = VelocityConnections.clientChannel(player)
val state = clientChannel.attr(STATE).setIfAbsent(PlayerAxiomState()) ?: clientChannel.attr(STATE).get()
state.session = null
val clientProtocol = player.protocolVersion.protocol
val serverProtocol = Via.proxyPlatform().protocolDetectorService().serverProtocolVersion(serverName).version
if (minOf(clientProtocol, serverProtocol) < MINIMUM_PROTOCOL) {
logger.info("Not translating Axiom for {} on {}: protocols {} -> {} are below {}. " +
"If the backend version is wrong, set it under velocity-servers in ViaVersion's config.",
player.username, serverName, clientProtocol, serverProtocol, MINIMUM_PROTOCOL)
backend.detach()
return
}
val session = AxiomProxySession(clientProtocol, serverProtocol, mappings.engine(clientProtocol, serverProtocol))
installServerbound(clientChannel, state)
state.session = session
backend.attach(session, state) { channel, bytes -> sendToClient(player, channel, bytes) }
logger.debug("Axiom session for {} on {}: protocol {} -> {}", player.username, serverName, clientProtocol, serverProtocol)
} catch (failure: Exception) {
logger.warn("Axiom translation unavailable for {} on {}: {}", player.username, serverName, failure.toString())
backend?.detach()
}
}
private fun sendToClient(player: Player, channel: String, bytes: ByteArray) {
player.sendPluginMessage(MinecraftChannelIdentifier.from(channel), bytes)
}
private fun installServerbound(channel: Channel, state: PlayerAxiomState) {
val pipeline = channel.pipeline()
if (pipeline.get(HANDLER_NAME) == null) {
pipeline.addAfter(VelocityConnections.MINECRAFT_DECODER, HANDLER_NAME, AxiomServerboundHandler(state, messages, logger))
}
}
private companion object {
/** Minecraft 1.21.5. The older palette format is not verified. */
const val MINIMUM_PROTOCOL = 770
const val HANDLER_NAME = "viaaxiom"
val STATE: AttributeKey<PlayerAxiomState> = AttributeKey.valueOf("viaaxiom:state")
}
}
@@ -0,0 +1,49 @@
package me.wargearsystems.viaaxiom.velocity
import com.viaversion.viaversion.api.Via
import me.wargearsystems.viaaxiom.translation.AxiomTranslationEngine
import me.wargearsystems.viaaxiom.translation.BlockStateMappingChain
import me.wargearsystems.viaaxiom.translation.IdentityBlockStateIdTranslator
import org.slf4j.Logger
import java.util.concurrent.ConcurrentHashMap
internal class ViaBlockMappings(private val logger: Logger) {
private val engines = ConcurrentHashMap<Pair<Int, Int>, AxiomTranslationEngine>()
fun engine(clientProtocol: Int, serverProtocol: Int): AxiomTranslationEngine =
engines.computeIfAbsent(clientProtocol to serverProtocol) { build(clientProtocol, serverProtocol) }
private fun build(clientProtocol: Int, serverProtocol: Int): AxiomTranslationEngine {
if (clientProtocol == serverProtocol) return AxiomTranslationEngine(IdentityBlockStateIdTranslator)
val protocols = Via.getManager().protocolManager
val path = protocols.getProtocolPath(clientProtocol, serverProtocol)
?: error("No ViaVersion path from protocol $clientProtocol to $serverProtocol. Is ViaBackwards installed?")
val steps = mutableListOf<IntArray>()
var clientRegistrySize = 0
for (entry in path.asReversed()) {
protocols.completeMappingDataLoading(entry.protocol().javaClass)
val mappings = entry.protocol().mappingData?.blockStateMappings ?: continue
steps += IntArray(mappings.size()) { mappings.getNewId(it) }
clientRegistrySize = mappings.mappedSize()
}
// Without block changes the registries match, and both sides use the 1.21.5 palette format.
if (steps.isEmpty()) return AxiomTranslationEngine(IdentityBlockStateIdTranslator)
var reportedMissing = false
val engine = BlockStateMappingChain.translationEngine(steps, clientRegistrySize, STONE_STATE_ID) { direction, id, fallback ->
if (!reportedMissing) {
reportedMissing = true
logger.warn("Axiom {} block state {} has no mapping; using stone placeholder {}", direction, id, fallback)
}
}
logger.info("Axiom mappings ready: protocol {} -> {}, block registries {} -> {}",
clientProtocol, serverProtocol, clientRegistrySize, steps.first().size)
return engine
}
private companion object {
/** The ID of minecraft:stone in every version since 1.13. */
const val STONE_STATE_ID = 1
}
}
@@ -0,0 +1,12 @@
{
"id": "viaaxiom",
"name": "ViaAxiom",
"version": "${version}",
"description": "Translates Axiom traffic between client and backend versions",
"authors": ["Jakob Schulz"],
"main": "me.wargearsystems.viaaxiom.velocity.ViaAxiomPlugin",
"dependencies": [
{ "id": "viaversion", "optional": false },
{ "id": "viabackwards", "optional": true }
]
}