blurite / rsprot

RSProt is an all-in-one networking package for RuneScape Private Servers, primarily targeted at the OldSchool scene.
MIT License
28 stars 7 forks source link

Shared Networking Library #1

Closed Z-Kris closed 5 months ago

Z-Kris commented 8 months ago

[!NOTE] This is a concept of a future library with the intent of sharing packet encoders and decoders for all private servers.

Prerequisites

Goals

Non-Goals

Motivation

Private server developers targeting the OldSchool builds are often spending days re-implementing the same packets that several others have done before them. We are often reinventing the wheel in the process too, coming up with various, often sub-par implementations of the protocol. While this won't be the easiest project to pull off, it would go a long way to simplifying the process for everyone involved, while also catapulting the OSRS protocol much further by lowering the skill requirement needed to enter the OSRS scene. Overall, this should significantly reduce the amount of time developers are spending on figuring out the packets each revision, as instead of everyone having to figure it out, it's only a single user that has to figure it out.

Protocol Constants

Protocol constants, such as the opcode and size of the packet, will be represented through an enum. The opcodes will be separated out into an internal object - this is to avoid having to scramble the enum every revision. Separating them allows us to have clean version history to see how packet sizes change over time, as well as any new additions or removals. It will also allow us to nicely group the constants according to their category. The enum containing opcodes will effectively be scrambled every revision, as it will be ordered according to the opcode, as it is found in the client.

Example of ServerProt:

[!NOTE] ClientProt will be identical to ServerProt, only with different constants.

internal object ServerProtId {
    const val IF_OPENSUB = 0
    const val MESSAGE_GAME = 1
}

enum class ServerProt(
    val id: Int,
    val size: Int,
) {
    IF_OPENSUB(ServerProtId.IF_OPENSUB, 7),
    MESSAGE_GAME(ServerProtId.MESSAGE_GAME, VAR_BYTE),
}

Messages

Each incoming and outgoing packet would be represented as a POJO-like message. These messages will be exposed to the server. Bitpacking will be utilized where possible to reduce the footprint of these classes.

All messages will implement a simple binding interface:

interface Message
interface IncomingMessage : Message
interface OutgoingMessage : Message

Example message:

data class MessageGame(
    val message: String,
    val targetUsername: String?,
    val type: Int,
) : OutgoingMessage

[!CAUTION] The developer is responsible for ensuring validity of the data passed into the constructors. In the above case, the type property is actually encoded as a single byte, if the developer provides a value outside of its boundaries, it will simply be subject to sanitization through bitwise-and (in this case, & 0xFF). The only exception to this is smarts, where the data must be within valid boundaries or it will throw an exception.

Message Encoding

Messages will be encoded through a MessageEncoder interface. This interface will be public and developers can create custom implementations of packets that they may have modified, rather than needing to fork the entire repository. Implementations of the packet encoders supplied by the library will be internal though.

interface MessageEncoder<in T : OutgoingMessage> {
    fun encode(
        buffer: ByteBuf,
        message: T,
    )
}

Examples of message encoders:

internal class IfOpenSubEncoder : MessageEncoder<IfOpenSub> {
    override fun encode(
        buffer: ByteBuf,
        message: IfOpenSub,
    ) {
        buffer.p2(message.interfaceId)
        buffer.p4(message.bitpackedTargetComponent)
        buffer.p1(message.type)
    }
}

internal class MessageGameEncoder : MessageEncoder<MessageGame> {
    override fun encode(
        buffer: ByteBuf,
        message: MessageGame,
    ) {
        buffer.pSmart1or2(message.type)
        val name = message.targetUsername
        if (name == null) {
            buffer.p1(0)
        } else {
            buffer.p1(1)
            buffer.pjstr(name)
        }
        buffer.pjstr(message.message)
    }
}

Message Decoding

Messages will be decoded through a MessageDecoder interface. This interface will be public and developers can create custom implementations of packets that they may have modified, rather than needing to fork the entire repository. Implementations of the packet decoders supplied by the library will be internal though, much like with encoders.

interface MessageDecoder<out T : IncomingMessage> {
    fun decode(buffer: ByteBuf): T
}

Examples of message decoders:

data class IfButton3 internal constructor(
    private val _interfaceId: UShort,
    private val _componentId: UShort,
    private val _slotId: UShort,
    private val _objId: UShort,
) : IncomingMessage {
    val interfaceId: Int
        get() = _interfaceId.toInt()
    val componentId: Int
        get() = _componentId.toInt()
    val slotId: Int
        get() = _slotId.toIntOrMinusOne()
    val objId: Int
        get() = _itemId.toIntOrMinusOne()
}

[!IMPORTANT] Packet properties will utilize the lowest possible data type due to 64-bit JVM memory alignment. This effectively means all classes will be aligned to a multiple-of-8-bytes, therefore it is still beneficial to use lower data types, such as in the above case, as the memory footprint will be lower. Packets which are naturally smaller, e.g. one that might only write a single byte, can still be represented in its integer form, as it will be aligned to 8 bytes anyhow. There are no benefits to be had from using value classes due to the guaranteed autoboxing occurring at multiple turns (generics, interfaces). If the class gets autoboxed, it will also be subject to the memory alignment, thus eliminating any benefits.