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,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 }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user