diff --git a/README.md b/README.md index 009acad39..7fc318653 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RSProt [![GitHub Actions][actions-badge]][actions] [![MIT license][mit-badge]][mit] -[![OldSchool - 221 - 235 (Alpha)](https://img.shields.io/badge/OldSchool-221--235_(Alpha)-9a1abd)](https://github.com/blurite/rsprot/tree/master/protocol/osrs-235/osrs-235-api/src/main/kotlin/net/rsprot/protocol/api) +[![OldSchool - 221 - 236 (Alpha)](https://img.shields.io/badge/OldSchool-221--236_(Alpha)-9a1abd)](https://github.com/blurite/rsprot/tree/master/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api) ## Status > [!NOTE] @@ -16,7 +16,7 @@ In order to add it to your server, add the below line under dependencies in your build.gradle.kts. ```kts -implementation("net.rsprot:osrs-235-api:1.0.0-ALPHA-20250124") +implementation("net.rsprot:osrs-236-api:1.0.0-ALPHA-20250203") ``` An in-depth tutorial on how to implement it will be added into this read-me @@ -32,12 +32,12 @@ other revisions are welcome, but will not be provided by default. - Java 11 ## Supported Versions -This library currently supports revision 221-235 OldSchool desktop clients. +This library currently supports revision 221-236 OldSchool desktop clients. ## Quick Guide This section covers a quick guide for how to use the protocol after implementing the base API. It is not a guide for the base API itself, that will come in the -future. This specific quick guide refers to revision 235. Revisions older +future. This specific quick guide refers to revision 236. Revisions older than 235 have a significantly different API and will not be explored here. It is recommented you upgrade to latest, or view an older readme in history. diff --git a/WHATSNEW.md b/WHATSNEW.md index 99a6ce10b..53a317b7a 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -1,5 +1,28 @@ ## What's New? +### Revision 236 +Revision 236 brings a handful of new variants of existing packets. + +New features: +- UPDATE_REBOOT_TIMER_V2 + - Adds a 'message' field to show alongside the update timer. + +The below packets all went through the same change - they now take absolute +coordinates relative to root world (not active!), rather than the previous +relative-to-build-area coordinates. Furthermore, all these packets are now +obfuscated, unlike their V1 counterparts. +- SET_MAP_FLAG_V2 +- CAM_MOVETO_V2 +- CAM_LOOKAT_V2 +- CAM_MOVETO_CYCLES_V2 +- CAM_LOOKAT_EASED_COORD_V2 +- CAM_MOVETO_ARC_V2 + +Changes: +- WORLDENTITY_INFO_V7 + - Bitpacks the width and length of the world entity sent in the add block into one byte. + - Extension info is now scrambled, bringing it in line with NPC and Player info packets. + ### Revision 235 Revision 235 brings no protocol level changes. However, as a small side note, OBJ packets are no longer limited to id 32767 diff --git a/build.gradle.kts b/build.gradle.kts index c998d06be..5bc7aeea5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ plugins { allprojects { group = "net.rsprot" - version = "1.0.0-ALPHA-20250124" + version = "1.0.0-ALPHA-20250203" repositories { mavenCentral() diff --git a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 5a698bd5e..54acbaebb 100644 --- a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -15,6 +15,7 @@ import net.rsprot.protocol.internal.checkCommunicationThread import net.rsprot.protocol.internal.game.outgoing.info.CoordFine import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import java.util.Collections /** * The world entity info class tracks everything about the world entities that @@ -78,8 +79,11 @@ public class WorldEntityInfo internal constructor( } private val unsortedTopKArray: WorldEntityUnsortedTopKArray = WorldEntityUnsortedTopKArray(MAX_HIGH_RES_COUNT) private val allWorldEntities = ArrayList() + private val allWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(allWorldEntities) private val addedWorldEntities = ArrayList() + private val addedWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(addedWorldEntities) private val removedWorldEntities = ArrayList() + private val removedWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(removedWorldEntities) private var buffer: ByteBuf? = null /** @@ -142,9 +146,9 @@ public class WorldEntityInfo internal constructor( * allowing for correct functionality for player and npc infos, as well as zone updates. * @return a list of indices of the world entities currently in high resolution. */ - internal fun getAllWorldEntityIndices(): List { + public fun getAllWorldEntityIndices(): List { if (isDestroyed()) return emptyList() - return this.allWorldEntities + return this.allWorldEntitiesUnmodifiable } /** @@ -154,9 +158,9 @@ public class WorldEntityInfo internal constructor( * @return a list of all the world entity indices added to the high resolution view in this * cycle. */ - internal fun getAddedWorldEntityIndices(): List { + public fun getAddedWorldEntityIndices(): List { if (isDestroyed()) return emptyList() - return this.addedWorldEntities + return this.addedWorldEntitiesUnmodifiable } /** @@ -166,9 +170,9 @@ public class WorldEntityInfo internal constructor( * @return a list of all the indices of the world entities that were removed from the high * resolution view this cycle. */ - internal fun getRemovedWorldEntityIndices(): List { + public fun getRemovedWorldEntityIndices(): List { if (isDestroyed()) return emptyList() - return this.removedWorldEntities + return this.removedWorldEntitiesUnmodifiable } /** diff --git a/protocol/osrs-236/build.gradle.kts b/protocol/osrs-236/build.gradle.kts new file mode 100644 index 000000000..da2c48d10 --- /dev/null +++ b/protocol/osrs-236/build.gradle.kts @@ -0,0 +1 @@ +// No-op diff --git a/protocol/osrs-236/osrs-236-api/build.gradle.kts b/protocol/osrs-236/osrs-236-api/build.gradle.kts new file mode 100644 index 000000000..fafb2d3a1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/build.gradle.kts @@ -0,0 +1,40 @@ +dependencies { + implementation(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + implementation(rootProject.libs.netty.transport) + implementation(rootProject.libs.netty.handler) + implementation(rootProject.libs.netty.native.epoll) + implementation(rootProject.libs.netty.native.kqueue) + implementation(rootProject.libs.netty.iouring) + implementation(rootProject.libs.netty.native.macos.dns.resolver) + val epollClassifiers = listOf("linux-aarch_64", "linux-x86_64", "linux-riscv64") + val kqueueClassifiers = listOf("osx-x86_64") + val iouringClassifiers = listOf("linux-aarch_64", "linux-x86_64") + for (classifier in epollClassifiers) { + implementation(variantOf(rootProject.libs.netty.native.epoll) { classifier(classifier) }) + } + for (classifier in kqueueClassifiers) { + implementation(variantOf(rootProject.libs.netty.native.kqueue) { classifier(classifier) }) + } + for (classifier in iouringClassifiers) { + implementation(variantOf(rootProject.libs.netty.iouring) { classifier(classifier) }) + } + implementation(rootProject.libs.inline.logger) + api(projects.protocol) + api(projects.compression) + api(projects.crypto) + api(projects.protocol.osrs236.osrs236Common) + api(projects.protocol.osrs236.osrs236Model) + implementation(projects.protocol.osrs236.osrs236Internal) + implementation(projects.protocol.osrs236.osrs236Desktop) + implementation(projects.protocol.osrs236.osrs236Shared) + implementation(projects.buffer) +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 API" + description = "The API module for revision 236 OldSchool RuneScape networking, " + + "offering an all-in-one implementation." + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/AbstractNetworkServiceFactory.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/AbstractNetworkServiceFactory.kt new file mode 100644 index 000000000..c7d7c4d3e --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/AbstractNetworkServiceFactory.kt @@ -0,0 +1,452 @@ +package net.rsprot.protocol.api + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBufAllocator +import io.netty.buffer.PooledByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.rsa.RsaKeyPair +import net.rsprot.protocol.api.binary.BinaryHeaderProvider +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder +import net.rsprot.protocol.api.config.NetworkConfiguration +import net.rsprot.protocol.api.game.GameDisconnectionReason +import net.rsprot.protocol.api.handlers.ExceptionHandlers +import net.rsprot.protocol.api.handlers.GameMessageHandlers +import net.rsprot.protocol.api.handlers.INetAddressHandlers +import net.rsprot.protocol.api.handlers.LoginHandlers +import net.rsprot.protocol.api.handlers.idlestate.IdleStateHandlerSuppliers +import net.rsprot.protocol.api.js5.Js5Configuration +import net.rsprot.protocol.api.js5.Js5DisconnectionReason +import net.rsprot.protocol.api.js5.Js5GroupProvider +import net.rsprot.protocol.api.login.LoginDisconnectionReason +import net.rsprot.protocol.api.obfuscation.OpcodeMapper +import net.rsprot.protocol.api.suppliers.NpcInfoSupplier +import net.rsprot.protocol.api.suppliers.PlayerInfoSupplier +import net.rsprot.protocol.api.suppliers.WorldEntityInfoSupplier +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.common.js5.outgoing.prot.Js5ServerProt +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFilter +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.message.codec.incoming.provider.GameMessageConsumerRepositoryProvider +import net.rsprot.protocol.metrics.NetworkTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.ConcurrentChannelTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.GameChannelTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.Js5ChannelTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.LoginChannelTrafficMonitor +import net.rsprot.protocol.metrics.impl.ConcurrentNetworkTrafficMonitor +import net.rsprot.protocol.metrics.impl.NoopNetworkTrafficMonitor +import net.rsprot.protocol.metrics.lock.TrafficMonitorLock +import org.jire.netty.haproxy.HAProxyMode + +/** + * The abstract network service factory is used to build the network service that is used + * as the entry point to this library, allowing one to bind the network and supply everything + * necessary network-wise from one spot. + * @param R the receiver type that will be consuming game messages, typically a Player + */ +@Suppress("MemberVisibilityCanBePrivate") +@ExperimentalUnsignedTypes +public abstract class AbstractNetworkServiceFactory { + /** + * The allocator that will be used for everything in this networking library. + * This will primarily be passed onto NPC and Player info objects, which will utilize + * this to precompute extended info blocks and the overall main buffer. + * It is HIGHLY recommended to use a pooled direct byte buffer if possible. + * Pooling in particular allows us to avoid allocating new expensive 40kb buffers + * per each player, for both player and npc infos, and direct buffers allow + * the Netty layer to skip one copy operation to move the data off of the heap. + */ + public open val allocator: ByteBufAllocator + get() = PooledByteBufAllocator.DEFAULT + + /** + * The host to which to bind to, defaulting to null. + */ + public open val host: String? + get() = null + + /** + * The list of ports to listen to. Typically, the server should listen to ports + * 43594 and 443 in this section, with the 43594 port being the primary one, + * and 443 being the fallback. 443 is additionally used for HTTP requests, should + * those be supported. + */ + public abstract val ports: List + + /** + * The list of client types to register within the network. + * If there are multiple client types implemented, one can supply multiple + * client types in this section. It is highly likely that only one will be + * offered though, as C++ clients are much harder to figure out. + * Furthermore, if multiple client types are offered, it is highly recommended + * to only register the ones you intend on supporting and using. This is because + * each client type that is registered here additionally means we have to precompute + * Player and NPC info extended info blocks for each of those client types, + * meaning all that work would go to a waste if no one is there to use the types. + */ + public abstract val supportedClientTypes: List + + /** + * Whether the client is connecting to this world under the beta world flag of + * 65536, or 0x10000. If this is the case, the login block that the client transmits + * differs from the usual one, as it ends up splitting CRCs up into two incomplete + * sections. This was intended to prevent people from downloading most of the + * beta cache without the access to the beta server, however the implementation + * is done incorrectly and all it ends up preventing is people who use the client + * to download the cache. The JS5 server is not notified of these constraints and + * will server every cache group as requested regardless of the status. + * Because of this, the implementation for the beta worlds is simplified to just + * support logging in via a beta world, which is accomplished by immediately + * requesting the remaining beta CRCs before passing the login request on to + * the server. By the time the server receives information about the request, + * all the CRCs have been obtained. + * It is worth noting that if the client flag status differs from the server, + * one of two possible scenarios will occur - either you will get an exception + * as the server tries to read more bytes than what the client wrote via the CRC + * block, or the server never receives enough bytes to consider the login block + * complete, which means the login request will hang and eventually time out. + */ + public open val betaWorld: Boolean + get() = false + + /** + * Gets the HAProxy mode to use for the network service. + * By default, HAProxy support is turned off. + */ + public open val haproxyMode: HAProxyMode + get() = HAProxyMode.OFF + + /** + * Gets the bootstrap factory builder to register the network service. + * The bootstrap builder offers the initial socket and Netty configurations + * to be used within this library. These configurations are by default + * made to mirror the client as much as possible. + */ + public open fun getBootstrapBuilder(): BootstrapBuilder = BootstrapBuilder() + + /** + * Gets the RSA key pair that will be used to decipher the login blocks + * sent by the client. If the keys aren't correct, the login block + * will fail to decode, and exceptions will be thrown. + */ + public abstract fun getRsaKeyPair(): RsaKeyPair + + /** + * Gets the Huffman codec provider. + * This is implemented in a provider format to allow the server to use + * blocking implementations, which allow lazy-loading Huffman. + * This is useful to allow binding the network early on in the boot + * cycle without having to wait behind Huffman. + * If a blocking implementation is used, and Huffman isn't ready when + * it is needed, the underlying Netty thread will be blocked until it + * is supplied. Because of this, it is recommended to use the + * non-blocking variant in production. + */ + public abstract fun getHuffmanCodecProvider(): HuffmanCodecProvider + + /** + * Gets the JS5 configuration settings to be used within the JS5 service. + * These settings allow a server to modify the frequency at which + * groups are served to the client, as well as the ratio between + * high priority logged in players and the low priority logged out ones, + * allowing the JS5 protocol to send more data to those logged in + * Furthermore, this allows defining the block size that is written + * per client per iteration. + * The default configuration is set to be fast enough to not show any + * client speed reduction via localhost. + */ + public open fun getJs5Configuration(): Js5Configuration = Js5Configuration() + + /** + * Gets the JS5 group provider, used to return the respective byte buffers + * or file regions from the server based on the incoming request. + * It is fine to use lazy-loading for development, but it is highly + * recommended to pre-compute the JS5 groups in the final form when + * used in development, to avoid instant no-delay responses. + */ + public abstract fun getJs5GroupProvider(): Js5GroupProvider + + /** + * Gets the consumer repository for incoming client game prots. + * This repository will be used to determine whether an incoming game packet + * needs decoding in the first place - if there is no consumer for the packet + * registered, we can simply skip the number of bytes that came in with that + * packet, avoiding any generation of garbage. Furthermore, the consumers + * will be automatically triggered for each incoming message when the server + * requests it via the [Session] object that is provided during login. + */ + public abstract fun getGameMessageConsumerRepositoryProvider(): GameMessageConsumerRepositoryProvider + + /** + * Gets the game connection handlers that are invoked whenever the client + * logs in or reconnects. These functions are only triggered after + * features such as Proof of Work and remaining beta archive CRCs + * have been obtained, furthermore additional library-sided checks, such as + * not too many connections from the same INetAddress must be met. + * Session id is also verified by the library before passing the request + * on to the implementing server. + * The server is responsible for doing all the login validation at this point, + * as well as CRC validations. + * It is worth noting that the connection handler will be invoked from whichever + * thread was used to decode the login block itself. + * By default, this will be one of the threads in the ForkJoinPool. + */ + public abstract fun getGameConnectionHandler(): GameConnectionHandler + + /** + * Gets the supplier for all the context that the NPC info protocol requires + * to function. The server must use the avatar factory to allocate + * avatars for each NPC that it spawned into the game. Furthermore, + * when the respective NPC is permanently remove from the game, + * the avatar MUST be de-allocated, otherwise that avatar will never be usable. + * For NPCs which are simply set to respawning mode, the avatar should be kept. + * For each player that logs into the world, one NPC info object should be + * allocated. Similarly to the avatar, this object MUST be deallocated + * when the player is removed from the game, be that normally or abnormally. + */ + public open fun getNpcInfoSupplier(): NpcInfoSupplier = NpcInfoSupplier() + + /** + * Gets the supplier for all the context that the Player info protocol requires + * to function. The server must allocate one Player info object per each + * player that logs in. Just like with NPC info, the object must be deallocated + * when the player is removed. + */ + public open fun getPlayerInfoSupplier(): PlayerInfoSupplier = PlayerInfoSupplier() + + public open fun getWorldEntityInfoSupplier(): WorldEntityInfoSupplier = WorldEntityInfoSupplier() + + /** + * Gets the exception handlers for channel exceptions as well as any incoming + * game message consumers that get processed in the library. + * Further implementations may be introduced in the future if the need arises. + */ + public open fun getExceptionHandlers(): ExceptionHandlers = + ExceptionHandlers( + { ctx, cause -> + if (ctx.channel().isActive) { + ctx.close() + } + logger.error(cause) { + "Exception in channel ${ctx.channel()}" + } + }, + ) + + /** + * Gets the handlers for anything related to INetAddresses. + * The default implementation will keep track of the number of concurrent + * game connections and JS5 connections separately. + * There is furthermore a validation implementation that will by default + * reject any connection to either service if there are 10 concurrent connections + * to that service from the same address already. + * The initial check is performed after the login connection, when either + * the game login or the JS5 connection is established. For game logins + * a secondary validation is performed right before the login block + * is passed onto the server to handle. + * It should be noted that the tracking mechanism is fairly straightforward + * and doesn't cost much in performance. + */ + public open fun getINetAddressHandlers(): INetAddressHandlers = INetAddressHandlers() + + /** + * Gets the handlers for game messages, which includes providing + * queue implementations for the incoming and outgoing messages, + * as well as a counter for incoming game messages, to avoid processing + * too many packets per cycle. + * The default implementation offers a ConcurrentLinkedQueue for both of + * the queues, and has a limit of 10 user packets, and 50 client packets. + * Only packets for which the server has registered a listener will be counted + * towards these limitations. Most packets will count towards the 10 user packets, + * as the client limitation is strictly for packets which cannot directly + * be influenced by the user. + * If either of the limitations is hit during message decoding, the decoding + * is halted immediately until after the game has polled the incoming messages. + * This means the TCP protocol is responsible for ensuring too much data cannot + * be passed onto us. + */ + public open fun getGameMessageHandlers(): GameMessageHandlers = GameMessageHandlers() + + /** + * Gets the handlers for the login related things. + * This includes an implementation for generating the initial session ids, + * which by default uses a secure random implementation. + * Additional configurations support modifying the stream cipher, proof of work + * logic, and a service for decoding logins, which is invoked using ForkJoinPool + * by default. + * The Proof of Work implementation uses the same inputs as used in the OldSchool + * as of writing this. It is possible to disable Proof of Work entirely, which + * can be useful for development environments. + */ + public open fun getLoginHandlers(): LoginHandlers = LoginHandlers() + + /** + * Gets the network traffic monitor which is responsible for tracking any incoming + * and outgoing packets, connections and more. + * The default is a [NoopNetworkTrafficMonitor] which does not store or track anything, + * but a [net.rsprot.protocol.metrics.impl.ConcurrentNetworkTrafficMonitor] can be used + * to enable tracking. Custom implementations are additionally also possible. + */ + public open fun getNetworkTrafficMonitor(): NetworkTrafficMonitor<*> = NoopNetworkTrafficMonitor + + /** + * Gets the network configuration builder, allowing one to modify various criteria regarding + * their network preferences. + */ + public open fun getNetworkConfiguration(): NetworkConfiguration.Builder = NetworkConfiguration.Builder() + + /** + * Gets the NPC avatar filter - a requirement for adding/keeping NPCs in high resolution. + * This is a server-side filter that can be customized to ones needs. + */ + public open fun getNpcAvatarFilter(): NpcAvatarFilter? { + return null + } + + /** + * An opcode mapper for client to server game packets. + * All incoming opcodes will be mapped immediately before any processing. + */ + public open fun getClientToServerOpcodeMapper(): OpcodeMapper? { + return null + } + + /** + * An opcode mapper for server to client game packets. + * All outgoing opcodes will be mapped right before writing to the buffer. + * Any computations prior will be using source opcodes. + */ + public open fun getServerToClientOpcodeMapper(): OpcodeMapper? { + return null + } + + /** + * Gets the binary header provider, or null if binary files should + * not be written. + * The binary header provider fills in the missing gaps that cannot + * be inferred within RSProt internally. + */ + public open fun getBinaryHeaderProvider(): BinaryHeaderProvider? { + return null + } + + /** + * Gets the [IdleStateHandlerSuppliers] which supply [io.netty.handler.timeout.IdleStateHandler]s for the + * [NetworkService]. + */ + public open fun getIdleStateHandlerSuppliers(): IdleStateHandlerSuppliers = IdleStateHandlerSuppliers() + + /** + * A Kotlin-only helper function to build a network configuration builder. + */ + @JvmSynthetic + public fun configure(block: (NetworkConfiguration.Builder).() -> Unit): NetworkConfiguration.Builder { + val builder = NetworkConfiguration.Builder() + block(builder) + return builder + } + + /** + * Builds a network service through this factoring, using all + * the information provided in here. + */ + public fun build(): NetworkService { + val allocator = this.allocator + val host = this.host + val ports = this.ports + val supportedClientTypes = this.supportedClientTypes + val huffman = getHuffmanCodecProvider() + val entityInfoProtocols = + EntityInfoProtocols.initialize( + allocator, + supportedClientTypes, + huffman, + getPlayerInfoSupplier(), + getNpcInfoSupplier(), + getWorldEntityInfoSupplier(), + getNpcAvatarFilter(), + ) + return NetworkService( + allocator, + host, + ports, + betaWorld, + getBootstrapBuilder(), + entityInfoProtocols, + supportedClientTypes, + getGameConnectionHandler(), + getExceptionHandlers(), + getINetAddressHandlers(), + getGameMessageHandlers(), + getLoginHandlers(), + getNetworkConfiguration().build(), + huffman, + getGameMessageConsumerRepositoryProvider(), + getNetworkTrafficMonitor(), + getClientToServerOpcodeMapper(), + getServerToClientOpcodeMapper(), + getRsaKeyPair(), + getJs5Configuration(), + getJs5GroupProvider(), + getIdleStateHandlerSuppliers(), + haproxyMode, + getBinaryHeaderProvider(), + ) + } + + public fun buildNetworkTrafficMonitor(): ConcurrentNetworkTrafficMonitor> { + val loginClientProts = enumValues() + val loginServerProts = enumValues() + val loginDisconnectionReasons = enumValues() + val js5ClientProts = enumValues() + val js5ServerProts = enumValues() + val js5DisconnectionReasons = enumValues() + val gameClientProts = enumValues() + val gameServerProts = enumValues() + val gameDisconnectionReasons = enumValues() + + val lock = TrafficMonitorLock() + val loginChannelTrafficHandler = + LoginChannelTrafficMonitor( + ConcurrentChannelTrafficMonitor( + lock, + loginClientProts, + loginServerProts, + loginDisconnectionReasons, + ), + ) + val js5ChannelTrafficHandler = + Js5ChannelTrafficMonitor( + ConcurrentChannelTrafficMonitor( + lock, + js5ClientProts, + js5ServerProts, + js5DisconnectionReasons, + ), + ) + val gameChannelTrafficHandler = + GameChannelTrafficMonitor( + ConcurrentChannelTrafficMonitor( + lock, + gameClientProts, + gameServerProts, + gameDisconnectionReasons, + ), + ) + + return ConcurrentNetworkTrafficMonitor>( + lock, + loginChannelTrafficHandler, + js5ChannelTrafficHandler, + gameChannelTrafficHandler, + ) + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/ChannelExceptionHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/ChannelExceptionHandler.kt new file mode 100644 index 000000000..93dac5dd6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/ChannelExceptionHandler.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.api + +import io.netty.channel.ChannelHandlerContext + +/** + * The channel exception handler is an interface that is invoked whenever Netty catches + * an exception in the channels. The server is expected to close the connection in any such case, + * if it is still open, and log the exception behind it. + */ +public fun interface ChannelExceptionHandler { + /** + * Invoked whenever a Netty handler catches an exception. + * @param ctx the channel handler context behind this connection + * @param cause the causation behind the exception + */ + public fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/EntityInfoProtocols.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/EntityInfoProtocols.kt new file mode 100644 index 000000000..d8f898801 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/EntityInfoProtocols.kt @@ -0,0 +1,248 @@ +package net.rsprot.protocol.api + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.api.suppliers.NpcInfoSupplier +import net.rsprot.protocol.api.suppliers.PlayerInfoSupplier +import net.rsprot.protocol.api.suppliers.WorldEntityInfoSupplier +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.npcinfo.DesktopLowResolutionChangeEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.writer.NpcAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.writer.PlayerAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.codec.worldentity.extendedinfo.WorldEntityAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.info.npcinfo.DeferredNpcInfoProtocolSupplier +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFactory +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFilter +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcResolutionChangeEncoder +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +/** + * The entity info protocols class brings together the relatively complex player and NPC info + * protocols. This is responsible for registering all the client types that are used by the user. + * @property playerAvatarFactory the avatar factory for players. Since players have a 1:1 player info + * to avatar ratio, the avatar is automatically included in the player info object that is requested. + * This is additionally strategically placed to improve cache locality and improve the performance + * of the player info protocol. + * @property playerInfoProtocol the main player info protocol responsible for computing the player + * info packet for all the players in the game. + * @property npcAvatarFactory the avatar factory for NPCs. Each NPC must allocate one avatar + * as they spawn, and that avatar must be deallocated when the NPC is fully removed from the game. + * @property npcInfoProtocol the main NPC info protocol responsible for computing the npc info packet + * for all the players in the game. + */ +public class EntityInfoProtocols + private constructor( + public val playerAvatarFactory: PlayerAvatarFactory, + public val playerInfoProtocol: PlayerInfoProtocol, + public val npcAvatarFactory: NpcAvatarFactory, + public val npcInfoProtocol: NpcInfoProtocol, + public val worldEntityAvatarFactory: WorldEntityAvatarFactory, + public val worldEntityInfoProtocol: WorldEntityProtocol, + ) { + internal companion object { + /** + * Initializes the player and NPC info avatar factories and protocols. + * @param allocator the byte buffer allocator used for player and NPC info main buffers, + * as well as any pre-computed extended info blocks. + * @param clientTypes the list of client types to register + * @param huffmanCodecProvider the Huffman codec provider that will be used to compute + * the chat extended info block, any others in the future that may require it. + * @param playerInfoSupplier the class wrapping the worker used to perform computations, + * as well as the filter for extended info blocks that ensures that the packet does not + * under any circumstances exceed the maximum packet limitations. + * @param npcInfoSupplier the class wrapping the worker used to perform computations, + * as well as the filter for extended info blocks that ensures that the packet does not + * under any circumstances exceed the maximum packet limitations. Furthermore, unlike + * player info, this will also provide an implementation for exceptions caught during + * pre-computations of NPC avatars. It is up to the server to decide how to handle + * any exceptions which are caught when computing information for avatars. The least + * destructive way is to remove the underlying NPC from the world when that happens, + * and log the exception in the process. This will still cause any observers to disconnect, + * however, but it ensures that anyone else that comes around the same area will not + * experience the same fate. There is also an implementation that is used to supply + * indices of nearby NPCs for the NPC info packet from the server's perspective, + * given a number of arguments necessary to determine it. The server is expected + * to return an iterator of all the indices of the NPCs that match the predicate, + * even if a NPC is already tracked by a given player. The protocol is responsible + * for ensuring no duplications will occur. + * @return a class wrapping all the protocols into one object. + */ + fun initialize( + allocator: ByteBufAllocator, + clientTypes: List, + huffmanCodecProvider: HuffmanCodecProvider, + playerInfoSupplier: PlayerInfoSupplier, + npcInfoSupplier: NpcInfoSupplier, + worldEntityInfoSupplier: WorldEntityInfoSupplier, + filter: NpcAvatarFilter?, + ): EntityInfoProtocols { + val playerWriters = mutableListOf() + val npcWriters = mutableListOf() + val npcResolutionChangeEncoders = mutableListOf() + val worldEntityWriters = mutableListOf() + if (OldSchoolClientType.DESKTOP in clientTypes) { + playerWriters += PlayerAvatarExtendedInfoDesktopWriter() + npcWriters += NpcAvatarExtendedInfoDesktopWriter() + npcResolutionChangeEncoders += DesktopLowResolutionChangeEncoder() + worldEntityWriters += WorldEntityAvatarExtendedInfoDesktopWriter() + } + val zoneIndexStorage = + ZoneIndexStorage( + ZoneIndexStorage.WORLDENTITY_CAPACITY, + ) + val worldEntityAvatarFactory = + buildWorldEntityAvatarFactory( + allocator, + zoneIndexStorage, + worldEntityWriters, + huffmanCodecProvider, + ) + val worldEntityProtocol = + buildWorldEntityInfoProtocol( + allocator, + worldEntityInfoSupplier, + worldEntityAvatarFactory, + zoneIndexStorage, + ) + val playerAvatarFactory = + buildPlayerAvatarFactory(allocator, playerInfoSupplier, playerWriters, huffmanCodecProvider) + val playerInfoProtocol = + buildPlayerInfoProtocol( + allocator, + playerInfoSupplier, + playerAvatarFactory, + ) + val storage = + ZoneIndexStorage( + ZoneIndexStorage.NPC_CAPACITY, + ) + val supplier = DeferredNpcInfoProtocolSupplier() + val npcAvatarFactory = + buildNpcAvatarFactory( + allocator, + npcInfoSupplier, + npcWriters, + huffmanCodecProvider, + storage, + supplier, + ) + val npcInfoProtocol = + buildNpcInfoProtocol( + allocator, + npcInfoSupplier, + npcResolutionChangeEncoders, + npcAvatarFactory, + storage, + filter, + ) + supplier.supply(npcInfoProtocol) + + return EntityInfoProtocols( + playerAvatarFactory, + playerInfoProtocol, + npcAvatarFactory, + npcInfoProtocol, + worldEntityAvatarFactory, + worldEntityProtocol, + ) + } + + private fun buildNpcInfoProtocol( + allocator: ByteBufAllocator, + npcInfoSupplier: NpcInfoSupplier, + npcResolutionChangeEncoders: MutableList, + npcAvatarFactory: NpcAvatarFactory, + zoneIndexStorage: ZoneIndexStorage, + filter: NpcAvatarFilter?, + ) = NpcInfoProtocol( + allocator, + ClientTypeMap.of( + npcResolutionChangeEncoders, + OldSchoolClientType.COUNT, + ) { + it.clientType + }, + npcAvatarFactory, + npcInfoSupplier.npcAvatarExceptionHandler, + npcInfoSupplier.npcInfoProtocolWorker, + zoneIndexStorage, + filter, + ) + + private fun buildNpcAvatarFactory( + allocator: ByteBufAllocator, + npcInfoSupplier: NpcInfoSupplier, + npcWriters: MutableList, + huffmanCodecProvider: HuffmanCodecProvider, + zoneIndexStorage: ZoneIndexStorage, + npcInfoProtocolSupplier: DeferredNpcInfoProtocolSupplier, + ): NpcAvatarFactory = + NpcAvatarFactory( + allocator, + npcInfoSupplier.npcExtendedInfoFilter, + npcWriters, + huffmanCodecProvider, + zoneIndexStorage, + npcInfoProtocolSupplier, + ) + + private fun buildWorldEntityAvatarFactory( + allocator: ByteBufAllocator, + zoneIndexStorage: ZoneIndexStorage, + worldEntityWriters: MutableList, + huffmanCodecProvider: HuffmanCodecProvider, + ): WorldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + zoneIndexStorage, + worldEntityWriters, + huffmanCodecProvider, + ) + + private fun buildWorldEntityInfoProtocol( + allocator: ByteBufAllocator, + worldEntityInfoSupplier: WorldEntityInfoSupplier, + worldEntityAvatarFactory: WorldEntityAvatarFactory, + zoneIndexStorage: ZoneIndexStorage, + ) = WorldEntityProtocol( + allocator, + worldEntityInfoSupplier.worldEntityAvatarExceptionHandler, + worldEntityAvatarFactory, + worldEntityInfoSupplier.worldEntityInfoProtocolWorker, + zoneIndexStorage, + ) + + private fun buildPlayerInfoProtocol( + allocator: ByteBufAllocator, + playerInfoSupplier: PlayerInfoSupplier, + playerAvatarFactory: PlayerAvatarFactory, + ): PlayerInfoProtocol = + PlayerInfoProtocol( + allocator, + playerInfoSupplier.playerInfoProtocolWorker, + playerAvatarFactory, + ) + + private fun buildPlayerAvatarFactory( + allocator: ByteBufAllocator, + playerInfoSupplier: PlayerInfoSupplier, + playerWriters: MutableList, + huffmanCodecProvider: HuffmanCodecProvider, + ): PlayerAvatarFactory = + PlayerAvatarFactory( + allocator, + playerInfoSupplier.playerExtendedInfoFilter, + playerWriters, + huffmanCodecProvider, + ) + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameConnectionHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameConnectionHandler.kt new file mode 100644 index 000000000..293ef9204 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameConnectionHandler.kt @@ -0,0 +1,47 @@ +package net.rsprot.protocol.api + +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.api.login.GameLoginResponseHandler +import net.rsprot.protocol.loginprot.incoming.util.AuthenticationType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * A handler interface for any game logins and reconnections. + * @param R the receiver of the incoming game packets, typically a Player class. + */ +public interface GameConnectionHandler { + /** + * The onLogin function is triggered whenever a login request is received by the library, + * and it passes all the initial validation necessary. The server is responsible + * for doing most of the validation here, but preliminary things like max number of connections + * and session ids will have been pre-checked by us. + * @param responseHandler the handler used to write a successful or failed login response, + * depending on the decisions made by the server. + * @param block the login block sent by the client, containing all the information the server + * will need. + */ + public fun onLogin( + responseHandler: GameLoginResponseHandler, + block: LoginBlock, + ) + + /** + * The onReconnect function is triggered whenever a reconnect request is received + * by the library. It is worth noting that Proof of Work will not be involved + * if this is the case, assuming it is enabled in the first place. + * Instead of transmitting the password, the client will transmit the seed used + * by the previous login connection. If the seed does not match with what the + * server knows, the request should be rejected. If the reconnect is successful, + * the server should replace the Session object in that player with the one + * provided by the response handler. The old session will close or time out shortly + * afterwards, if it already hasn't. + * @param responseHandler the handler used to write a successful or failed reconnect response, + * depending on the decisions made by the server. + * @param block the login block sent by the client, containing all the information the server + * will need. + */ + public fun onReconnect( + responseHandler: GameLoginResponseHandler, + block: LoginBlock, + ) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounter.kt new file mode 100644 index 000000000..9c5b6d487 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounter.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.api + +import net.rsprot.protocol.ClientProtCategory + +/** + * An interface for tracking incoming game messages, in order to avoid + * decoding and consuming too many messages if the client is flooding us + * with them. + * This implementation must be thread safe in the sense that the + * increment and reset functions could be called concurrently from different + * threads. The default implementation uses an array for tracking the counts + * and thus does not need such thread safety here. + */ +public interface GameMessageCounter { + /** + * Increments the message counter for the provided client prot + * category. + * @param clientProtCategory the category of the incoming packet. + */ + public fun increment(clientProtCategory: ClientProtCategory) + + /** + * Whether any of the message categories have reached their limit + * for maximum number of decoded messages. + */ + public fun isFull(): Boolean + + /** + * Resets the tracked counts for the messages. + */ + public fun reset() +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounterProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounterProvider.kt new file mode 100644 index 000000000..966e5b2ad --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/GameMessageCounterProvider.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.api + +/** + * Gets the message counter provider for incoming game messages. + * This is in a provider implementation as one instance is allocated + * for each session object. + */ +public fun interface GameMessageCounterProvider { + /** + * Provides a game message counter implementation. + * A new instance must be allocated with each request, + * as this is per session basis. + */ + public fun provide(): GameMessageCounter +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/IncomingGameMessageConsumerExceptionHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/IncomingGameMessageConsumerExceptionHandler.kt new file mode 100644 index 000000000..d37fcf405 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/IncomingGameMessageConsumerExceptionHandler.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.api + +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * An exception handler for exceptions caught during the invocation of game message + * consumers for a given session. As the server only calls one function + * to process all the incoming messages, any one of them could throw an exception + * half-way through, thus we need a handler to safely deal with exceptions if that + * were to happen. + * @param R the receiver of the session object, typically a player. + */ +public fun interface IncomingGameMessageConsumerExceptionHandler { + /** + * Triggered whenever an throwable is caught when invoking the incoming + * game message consumers for all the packets that came in. + * @param session the session which triggered the exception + * @param packet the incoming game message that failed to be processed + * @param cause the throwable being caught. Note that because this catches + * throwables, it will also catch errors, which likely should be propagated + * further. + */ + public fun exceptionCaught( + session: Session, + packet: IncomingGameMessage, + cause: Throwable, + ) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressTracker.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressTracker.kt new file mode 100644 index 000000000..49bcc40dd --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressTracker.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.api + +/** + * The tracker implementation for INetAddresses. + * This implementation must be thread safe, as it is triggered by all kinds + * of Netty threads! + */ +public interface InetAddressTracker { + /** + * The register function is invoked whenever a channel goes active + * @param address the address that connected + */ + public fun register(address: String) + + /** + * The deregister function is invoked whenever a channel goes inactive + * @param address the address that disconnected + */ + public fun deregister(address: String) + + /** + * Gets the number of active connections for a given address + * @param address the address to check + */ + public fun getCount(address: String): Int +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressValidator.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressValidator.kt new file mode 100644 index 000000000..34ee6d090 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/InetAddressValidator.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.api + +/** + * The validation service for [String]. + * This service is responsible for accepting of rejecting connections based + * on the number of active connections from said service. + * It is worth noting that game and JS5 are tracked separately, as each + * client opened will initiate a request to both. + * Any connections opened at the very start before either JS5 or + * game has been decided will not be validated, as it is unclear to which + * end point they wish to connect. Those sessions will time out after 30 seconds + * if no decision has been made. + */ +public interface InetAddressValidator { + /** + * Whether to accept a game connection from the provided [address] + * based on the current number of active game connections + * @param address the address attempting to establish a game connection + * @param activeGameConnections the number of currently active game connections from that address + */ + public fun acceptGameConnection( + address: String, + activeGameConnections: Int, + ): Boolean + + /** + * Whether to accept a JS5 connection from the provided [address] + * based on the current number of active Js5 connections + * @param address the address attempting to establish a JS5 connection + * @param activeJs5Connections the number of currently active JS5 connections from that address + * @param seed the seed used for reconnections and xtea block decryption. + */ + public fun acceptJs5Connection( + address: String, + activeJs5Connections: Int, + seed: IntArray, + ): Boolean +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginChannelInitializer.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginChannelInitializer.kt new file mode 100644 index 000000000..2c5177f87 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginChannelInitializer.kt @@ -0,0 +1,51 @@ +package net.rsprot.protocol.api + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.Channel +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInitializer +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.login.LoginChannelHandler +import net.rsprot.protocol.api.login.LoginMessageDecoder +import net.rsprot.protocol.api.login.LoginMessageEncoder + +/** + * The channel initializer for login blocks. + * This initializer will add the login channel handler as well as an + * idle state handler to ensure the connections are cut short if they go idle. + */ +public class LoginChannelInitializer( + private val networkService: NetworkService, +) : ChannelInitializer() { + override fun initChannel(ch: Channel) { + networkLog(logger) { + "Channel initialized: $ch" + } + networkService.trafficMonitor.incrementConnections() + ch.pipeline().addLast( + networkService.idleStateHandlerSuppliers.initialSupplier.supply(), + LoginMessageDecoder(networkService), + LoginMessageEncoder(networkService), + LoginChannelHandler(networkService), + ) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) { + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, cause) + val channel = ctx.channel() + if (channel.isOpen) { + channel.close() + } + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginDecoderService.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginDecoderService.kt new file mode 100644 index 000000000..2d7868418 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/LoginDecoderService.kt @@ -0,0 +1,45 @@ +package net.rsprot.protocol.api + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.LoginBlockDecodingFunction +import java.util.concurrent.CompletableFuture + +/** + * The service behind decoding login blocks. + * This is needed as the login blocks take a noticeable amount of time to decode, + * and it may not be ideal to block the Netty threads from doing any work during + * that time. Most of the time is taken up by the RSA deciphering, + * which can take around a millisecond for a secure key. + */ +public interface LoginDecoderService { + /** + * Decodes a login block buffer using the [decoder] implementation. + * The default implementation for login decoder utilizes a ForkJoinPool. + * @param buffer the buffer to decode + * @param betaWorld whether the login connection came from a beta world, + * which means the decoding is only partial + * @param header the header of the login block that was previously decoded. + * @param decoder the decoder function used to turn the buffer into a login block + * @return a completable future instance that may be completed on a different thread, + * to avoid blocking Netty threads. + */ + public fun decode( + buffer: JagByteBuf, + betaWorld: Boolean, + header: LoginBlock.Header, + decoder: LoginBlockDecodingFunction, + ): CompletableFuture> + + /** + * Decodes the header block of login. + * This is necessary to determine the difficulty level for proof of work, + * as we need to know whether we're dealing with a mobile or a desktop user. + * @param buffer the buffer to decode + * @param decoder the decode to use for decoding the header block + */ + public fun decodeHeader( + buffer: JagByteBuf, + decoder: LoginBlockDecodingFunction<*>, + ): LoginBlock.Header +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/MessageQueueProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/MessageQueueProvider.kt new file mode 100644 index 000000000..22fc8b80c --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/MessageQueueProvider.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.api + +import net.rsprot.protocol.message.Message +import java.util.Queue + +/** + * The queue provider for any type of messages. + * The queue must be thread-safe! + * The default implementation is a ConcurrentLinkedQueue. + * @param T the type of the message that the queue handles, either incoming or outgoing. + */ +public fun interface MessageQueueProvider { + /** + * Provides a new instance of the message queue. This should always + * return a new instance of the queue implementation. + */ + public fun provide(): Queue +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/NetworkService.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/NetworkService.kt new file mode 100644 index 000000000..4838c7b55 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/NetworkService.kt @@ -0,0 +1,290 @@ +package net.rsprot.protocol.api + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBufAllocator +import io.netty.channel.ChannelFuture +import io.netty.channel.EventLoopGroup +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.rsa.RsaKeyPair +import net.rsprot.protocol.api.binary.BinaryHeaderProvider +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder +import net.rsprot.protocol.api.config.NetworkConfiguration +import net.rsprot.protocol.api.handlers.ExceptionHandlers +import net.rsprot.protocol.api.handlers.GameMessageHandlers +import net.rsprot.protocol.api.handlers.INetAddressHandlers +import net.rsprot.protocol.api.handlers.LoginHandlers +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import net.rsprot.protocol.api.handlers.idlestate.IdleStateHandlerSuppliers +import net.rsprot.protocol.api.js5.ConcurrentJs5Authorizer +import net.rsprot.protocol.api.js5.Js5Authorizer +import net.rsprot.protocol.api.js5.Js5Configuration +import net.rsprot.protocol.api.js5.Js5GroupProvider +import net.rsprot.protocol.api.js5.Js5Service +import net.rsprot.protocol.api.js5.NoopJs5Authorizer +import net.rsprot.protocol.api.obfuscation.OpcodeMapper +import net.rsprot.protocol.api.repositories.MessageDecoderRepositories +import net.rsprot.protocol.api.repositories.MessageEncoderRepositories +import net.rsprot.protocol.api.util.asCompletableFuture +import net.rsprot.protocol.common.RSProtConstants +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.InfoProtocols +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFactory +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory +import net.rsprot.protocol.message.codec.incoming.provider.GameMessageConsumerRepositoryProvider +import net.rsprot.protocol.metrics.NetworkTrafficMonitor +import net.rsprot.protocol.threads.IllegalThreadAccessException +import org.jire.netty.haproxy.HAProxy.childHandlerProxied +import org.jire.netty.haproxy.HAProxyMode +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.time.measureTime +import net.rsprot.protocol.internal.setCommunicationThread as setInternalCommunicationThread + +/** + * The primary network service implementation that brings all the necessary components together + * in a single "god" object. + * @param R the receiver type for the incoming game message consumers, typically a player + * @property allocator the byte buffer allocator used throughout the library + * @property host the host to which to bind to, defaulting to null. + * @property ports the list of ports that the service will connect to + * @property betaWorld whether this world is a beta world + * @property bootstrapBuilder the bootstrap builder used to configure the socket and Netty + * @property entityInfoProtocols a wrapper object to bring together player and NPC info protocols + * @property clientTypes the list of client types that were registered + * @property gameConnectionHandler the handler for game logins and reconnections + * @property exceptionHandlers the wrapper object for any exception handlers that the server must provide + * @property gameMessageHandlers the wrapper object for anything to do with game packets post-login + * @property huffmanCodecProvider the provider for Huffman codecs, used to compress the text + * in some packets + * @property gameMessageConsumerRepositoryProvider the consumer repository for game messages. + * This is made public in case a blocking implementation is used, in which case the + * repository may be lazy-initialized into this library. + * @param rsaKeyPair the key pair for RSA to decode login blocks + * @param js5Configuration the configuration used by the JS5 service to determine the exact conditions + * for serving any connected clients + * @param js5GroupProvider the provider for any JS5 requests that the client makes + * @property encoderRepositories the encoder repositories for all the connection types + * @property js5Service the service behind the JS5, serving all connected clients fairly + * @property js5ServiceExecutor the thread executing the JS5 service. Since the main JS5 + * service is fairly lightweight and doesn't actually process much, a single thread + * is more than sufficient here. Utilizing more threads makes implementing a fair JS5 + * service significantly more difficult. + * @property decoderRepositories the repositories for decoding all the incoming client packets + * @property npcAvatarFactory the avatar factory for NPCs responsible for tracking anything + * necessary to represent a NPC to the client + * @property infoProtocols a unified class used to allocate and destroy the three protocols. + * @property trafficMonitor a monitor for tracking network traffic, by default a no-op + * implementation that tracks nothing. + * @property binaryHeaderProvider a provider for binary headers. If this is null, or returns a null, + * a binary header is not built for the given session. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class NetworkService + internal constructor( + public val allocator: ByteBufAllocator, + public val host: String?, + public val ports: List, + public val betaWorld: Boolean, + public val bootstrapBuilder: BootstrapBuilder, + internal val entityInfoProtocols: EntityInfoProtocols, + public val clientTypes: List, + internal val gameConnectionHandler: GameConnectionHandler, + internal val exceptionHandlers: ExceptionHandlers, + internal val iNetAddressHandlers: INetAddressHandlers, + internal val gameMessageHandlers: GameMessageHandlers, + internal val loginHandlers: LoginHandlers, + internal val configuration: NetworkConfiguration, + public val huffmanCodecProvider: HuffmanCodecProvider, + public val gameMessageConsumerRepositoryProvider: GameMessageConsumerRepositoryProvider, + public val trafficMonitor: NetworkTrafficMonitor<*>, + public val clientToServerOpcodeMapper: OpcodeMapper?, + public val serverToClientOpcodeMapper: OpcodeMapper?, + rsaKeyPair: RsaKeyPair, + js5Configuration: Js5Configuration, + js5GroupProvider: Js5GroupProvider, + public val idleStateHandlerSuppliers: IdleStateHandlerSuppliers, + public val haProxyMode: HAProxyMode, + public val binaryHeaderProvider: BinaryHeaderProvider?, + ) { + public var encoderRepositories: MessageEncoderRepositories = MessageEncoderRepositories(huffmanCodecProvider) + public val js5Authorizer: Js5Authorizer = if (betaWorld) ConcurrentJs5Authorizer() else NoopJs5Authorizer + public val js5Service: Js5Service = + Js5Service( + this, + js5Configuration, + js5GroupProvider, + js5Authorizer, + ) + public val js5ServiceExecutor: Thread = + thread(start = false, name = "Js5 Service") { + js5Service.run() + } + public var decoderRepositories: MessageDecoderRepositories = + MessageDecoderRepositories.initialize( + clientTypes, + rsaKeyPair, + huffmanCodecProvider, + ) + public val infoProtocols: InfoProtocols = + InfoProtocols( + entityInfoProtocols.playerInfoProtocol, + entityInfoProtocols.npcInfoProtocol, + entityInfoProtocols.worldEntityInfoProtocol, + ) + public val npcAvatarFactory: NpcAvatarFactory + get() = entityInfoProtocols.npcAvatarFactory + public val worldEntityAvatarFactory: WorldEntityAvatarFactory + get() = entityInfoProtocols.worldEntityAvatarFactory + public var messageSizeEstimator: OutgoingMessageSizeEstimator = + OutgoingMessageSizeEstimator(encoderRepositories) + + public lateinit var bossGroup: EventLoopGroup + public lateinit var childGroup: EventLoopGroup + public lateinit var js5PrefetchService: ScheduledExecutorService + + /** + * Starts the network service by binding the provided ports. + * If any of them fail, the service is shut down and the exception is propagated forward. + */ + @ExperimentalUnsignedTypes + @ExperimentalStdlibApi + public fun start() { + val time = + measureTime { + val bootstrap = bootstrapBuilder.build(messageSizeEstimator) + val initializer = LoginChannelInitializer(this) + bootstrap.childHandlerProxied(initializer, haProxyMode) + this.bossGroup = bootstrap.config().group() + this.childGroup = bootstrap.config().childGroup() + val host = this.host + val futures = + ports + .map { if (host != null) bootstrap.bind(host, it) else bootstrap.bind(it) } + .map>(ChannelFuture::asCompletableFuture) + val future = CompletableFuture.allOf(*futures.toTypedArray()) + js5ServiceExecutor.start() + js5PrefetchService = Js5Service.startPrefetching(js5Service) + future.join() + } + logger.info { "Started in: $time" } + logger.info { "Bound to ports: ${ports.joinToString(", ")}" } + logger.info { "Revision: ${RSProtConstants.REVISION}" } + val clientTypeNames = + clientTypes.joinToString(", ") { + it.name.lowercase().replaceFirstChar(Char::uppercase) + } + logger.info { "Supported client types: $clientTypeNames" } + } + + /** + * Shuts the network service down and blocks the calling thread for up to [timeout] [timeUnit]. + * If any part of the shutdown throws an Exception, it will be caught and logged, but it will not + * be propagated forward. Error types are logged too, but those *will* be propagated forward. + * @param quietPeriod the time Netty's event executor groups will wait for initially to make + * sure no new tasks are submitted. If any new task is submitted, the timer is reset. + * If the [timeout] is reached, it will forcibly shut down anyway. This is part of a graceful + * shutdown procedure. + * @param timeout the timeout to wait for before forcibly shutting down all the services. + * @param timeUnit the time unit used for both periods. + */ + public fun shutdownNow( + quietPeriod: Long = 2L, + timeout: Long = 15L, + timeUnit: TimeUnit = TimeUnit.SECONDS, + ) { + val future = shutdown(quietPeriod, timeout, timeUnit) + try { + future.join() + } catch (e: Exception) { + logger.error(e) { + "Network service may have not successfully shut down." + } + } catch (t: Throwable) { + logger.error(t) { + "Network service may have not successfully shut down." + } + throw t + } + } + + /** + * Submits a request to shut down the network service, returning a [CompletableFuture]. Calling + * this function will not block the calling thread. + * @param quietPeriod the time Netty's event executor groups will wait for initially to make + * sure no new tasks are submitted. If any new task is submitted, the timer is reset. + * If the [timeout] is reached, it will forcibly shut down anyway. This is part of a graceful + * shutdown procedure. + * @param timeout the timeout to wait for before forcibly shutting down all the services. + * @param timeUnit the time unit used for both periods. + */ + @JvmOverloads + public fun shutdown( + quietPeriod: Long = 2L, + timeout: Long = 15L, + timeUnit: TimeUnit = TimeUnit.SECONDS, + ): CompletableFuture { + return CompletableFuture.allOf( + CompletableFuture.runAsync { + js5Service.triggerShutdown() + js5ServiceExecutor.join(timeUnit.toMillis(timeout)) + }, + CompletableFuture.runAsync { + if (this::js5PrefetchService.isInitialized) { + js5PrefetchService.safeShutdown(timeout, timeUnit) + } + }, + CompletableFuture.runAsync { + if (this::bossGroup.isInitialized) { + bossGroup.shutdownGracefully(quietPeriod, timeout, timeUnit) + } + }, + CompletableFuture.runAsync { + if (this::childGroup.isInitialized) { + childGroup.shutdownGracefully(quietPeriod, timeout, timeUnit) + } + }, + ) + } + + private fun ExecutorService.safeShutdown( + timeout: Long, + timeUnit: TimeUnit, + ) { + shutdown() + try { + if (!awaitTermination(timeout, timeUnit)) { + shutdownNow() + } + } catch (_: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } + + /** + * Sets the thread which is permitted to communicate with RSProt's thread-unsafe + * properties. If set to null, all threads are allowed to communicate again. + * @param thread the thread permitted to communicate with RSProt's thread-unsafe functions. + * @param warnOnError whether to warn on a thread violation error. If false, am + * [IllegalThreadAccessException] is thrown instead. + */ + @JvmOverloads + public fun setCommunicationThread( + thread: Thread?, + warnOnError: Boolean = true, + ) { + setInternalCommunicationThread(thread, warnOnError) + } + + /** + * Checks whether the provided [clientType] is supported by the service. + */ + public fun isSupported(clientType: OldSchoolClientType): Boolean = clientType in clientTypes + + private companion object { + private val logger = InlineLogger() + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/Session.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/Session.kt new file mode 100644 index 000000000..7378fcdb9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/Session.kt @@ -0,0 +1,569 @@ +package net.rsprot.protocol.api + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.Unpooled +import io.netty.channel.ChannelFutureListener +import io.netty.channel.ChannelHandlerContext +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.api.game.GameDisconnectionReason +import net.rsprot.protocol.api.game.GameMessageDecoder +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.binary.BinaryBlob +import net.rsprot.protocol.channel.getBinaryBlobOrNull +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.MapProjAnimV2 +import net.rsprot.protocol.game.outgoing.zone.payload.ObjAdd +import net.rsprot.protocol.game.outgoing.zone.payload.ObjCount +import net.rsprot.protocol.game.outgoing.zone.payload.ObjCustomise +import net.rsprot.protocol.game.outgoing.zone.payload.ObjDel +import net.rsprot.protocol.game.outgoing.zone.payload.ObjEnabledOps +import net.rsprot.protocol.game.outgoing.zone.payload.ObjUncustomise +import net.rsprot.protocol.game.outgoing.zone.payload.SoundArea +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.LoginClientType +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.codec.incoming.MessageConsumer +import net.rsprot.protocol.metrics.NetworkTrafficMonitor +import java.util.Queue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +/** + * The session objects are used to link the player instances together with the respective + * session instances. + * @param R the receiver of the game message consumers, typically a player + * @property ctx the channel handler context behind this session, public in case the server + * needs to directly manage it + * @property incomingMessageQueue the message queue for incoming game messages + * @param outgoingMessageQueueProvider the provider for outgoing message queues. + * One queue is allocated for each possible game server prot category. + * @property counter the incoming message counter used to determine whether to stop + * decoding packets after too many have flooded in over a single cycle. + * @property consumers the map of incoming game message classes to the consumers of + * said messages + * @property loginBlock the login block that resulted in this session being constructed + * @property incomingGameMessageConsumerExceptionHandler the exception handler responsible + * for managing any exceptions during the game message processing + * @property outgoingMessageQueues the array of outgoing game messages, categorized based + * on the server prots. This is because some packets are given priority and written + * to the client first, despite often being computed near the end of the cycle. + * @property hostAddress the inet address behind this connection + * @property disconnectionHook the disconnection hook to trigger if the channel happens + * to disconnect. It should be noted that it is the server's responsibility to set + * the hook after a successful login. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class Session( + private val trafficMonitor: NetworkTrafficMonitor<*>, + public val ctx: ChannelHandlerContext, + private val incomingMessageQueue: Queue, + outgoingMessageQueueProvider: MessageQueueProvider, + private val counter: GameMessageCounter, + private val consumers: Map, MessageConsumer>, + private val globalConsumers: List>, + public val loginBlock: LoginBlock<*>, + private val incomingGameMessageConsumerExceptionHandler: IncomingGameMessageConsumerExceptionHandler, +) { + private val outgoingMessageQueues: Array> = + Array(GameServerProtCategory.COUNT) { + outgoingMessageQueueProvider.provide() + } + public val inetAddress: String = ctx.hostAddress() + private var disconnectionHook: AtomicReference = AtomicReference(null) + + @Volatile + private var channelStatus: ChannelStatus = ChannelStatus.OPEN + + private var lastFlush: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() + + private var lowPriorityCategoryPacketsDiscarded: AtomicBoolean = AtomicBoolean() + + private var loginTransitionStatus: AtomicInteger = AtomicInteger(0) + + /** + * Discards any packets which have a [GameServerProtCategory.LOW_PRIORITY_PROT] category. + * + * In Old School RuneScape, this is used on logout. Only packets which are marked as high priority + * category appear to get transmitted on the game cycle on which the player clicks the logout button. + * This function should only be invoked when the player is guaranteed to be getting logged out. + * + * Note that info packets (player info, npc info, worldentity info) appear to also not send out + * once the player enters the logging out state, even though they are in high priority categories. + * The server should take care of it in such cases. + * + * The effects of this function take place on Netty's event loop threads, specifically when [flush] + * is invoked. If the low priority category packets are disabled, rather than passing them into + * the pipeline, the packets will be safely released and discarded. + * Furthermore, it is worth noting that [flush] can be invoked on RSProt's own volition, + * when there's a lot of backpressure and everything could not be flushed out in one go. + */ + @JvmOverloads + public fun discardLowPriorityCategoryPackets(discard: Boolean = true) { + lowPriorityCategoryPacketsDiscarded.set(discard) + } + + private fun updateLastFlush() { + lastFlush = TimeSource.Monotonic.markNow() + } + + /** + * Gets the binary blob associated with this channel, or null if one wasn't previously set up. + */ + public fun getBinaryBlobOrNull(): BinaryBlob? { + return ctx.channel().getBinaryBlobOrNull() + } + + /** + * Checks if the channel has gone idle in a limbo state. + * It is unclear how or why this happens, but it rarely does occur - Netty's hook + * does not get triggered, leaving the connection in a limbo-open status. + */ + private fun checkIdle() { + val elapsed = lastFlush.elapsedNow() + if (elapsed < limboIdleDuration) return + logger.warn { + "Connection ${ctx.channel()} has gone idle in limbo, " + + "requesting channel close for '${loginBlock.username}'." + } + triggerIdleClosing() + } + + /** + * Triggers the channel closing due to idling, if it hasn't yet been called. + */ + internal fun triggerIdleClosing() { + if (internalRequestClose()) { + invokeDisconnectionHook() + } + } + + /** + * Queues a game message to be written to the client based on the message's defined + * category + * @param message the outgoing game message to queue in its respective message queue + */ + public fun queue(message: OutgoingGameMessage) { + queue(message, message.category) + } + + /** + * Queues a game message to be written to the client based on the provided message + * category, in case one wishes to override the categories defined by the library + * @param message the outgoing game message + * @param category the category of the queue to put the message in + */ + public fun queue( + message: OutgoingGameMessage, + category: ServerProtCategory, + ) { + if (this.channelStatus != ChannelStatus.OPEN) { + message.safeRelease() + return + } + message.markConsumed() + if (RSProtFlags.filterMissingPacketsInClient) { + validateMessage(message) + } + val categoryId = category.id + val queue = outgoingMessageQueues[categoryId] + queue += message + checkIdle() + } + + /** + * Validates the message to ensure the client can actually handle it as a stand-alone packet. + * This function should eventually be eliminated and these zone prots should not implement + * the base [OutgoingGameMessage] class, so they could never be passed in to begin with. + */ + private fun validateMessage(message: OutgoingGameMessage) { + if (loginBlock.clientType == LoginClientType.DESKTOP && message is SoundArea) { + throw IllegalArgumentException( + "SoundArea packet may only be sent as part of " + + "partial enclosed as of revision 221 on Java clients. Packet: $message", + ) + } + if (message is MapProjAnimV2 || + message is ObjAdd || + message is ObjDel || + message is ObjCount || + message is ObjEnabledOps || + message is ObjCustomise || + message is ObjUncustomise + ) { + throw IllegalArgumentException( + "${message.javaClass.simpleName} can only be sent as " + + "part of UpdateZonePartialEnclosed.", + ) + } + } + + /** + * Iterates through all the incoming packets over the last cycle and invokes the + * consumer with the receiver on them. If an exception is caught, it is propagated + * forward to the respective exception handler. + * If too many messages were received from the server during the last cycle, + * the decoder will have stopped accepting any new packets. If that was the case + * at the end of this function, the packet decoding will resume. + * @param receiver the receiver on whom to invoke the message consumers, + * typically a player instance + * @return the number of consumers that was invoked, this is handy in case + * one wishes to manually track the idle status serverside and potentially + * log the player out earlier if no packets are received over a number of cycles. + */ + public fun processIncomingPackets(receiver: R): Int { + if (this.channelStatus != ChannelStatus.OPEN) return 0 + var count = 0 + while (true) { + val packet = pollIncomingMessage() ?: break + val consumer = consumers[packet::class.java] + checkNotNull(consumer) { + "Consumer for packet $packet does not exist." + } + networkLog(logger) { + "Processing incoming game packet from channel '${ctx.channel()}': $packet" + } + try { + consumer.consume(receiver, packet) + } catch (cause: Throwable) { + incomingGameMessageConsumerExceptionHandler.exceptionCaught(this, packet, cause) + } + if (globalConsumers.isNotEmpty()) { + for (globalConsumer in globalConsumers) { + try { + globalConsumer.consume(receiver, packet) + } catch (cause: Throwable) { + incomingGameMessageConsumerExceptionHandler.exceptionCaught(this, packet, cause) + } + } + } + count++ + } + onPollComplete() + return count + } + + /** + * Sets a disconnection hook to be triggered if the connection to this channel is lost, + * allowing one to safely log the player out in such case. If the channel has already disconnected + * by the time this function is invoked, the [hook] will be executed __immediately__. + * @param hook the hook runnable to invoke if the connection is lost + * @throws IllegalStateException if a hook was already registered + */ + public fun setDisconnectionHook(hook: Runnable) { + val currentHook = this.disconnectionHook + val assigned = currentHook.compareAndSet(null, hook) + if (!assigned) { + throw IllegalStateException("A disconnection hook has already been registered!") + } + // Immediately trigger the disconnection hook if the channel already went inactive before + // the hook could be triggered + if (!ctx.channel().isActive) { + if (internalRequestClose()) { + invokeDisconnectionHook() + } + } + } + + /** + * Requests the channel to be closed once there's nothing more to write out, and the + * channel has been flushed. This will furthermore clear any disconnection hook set, + * to avoid any lingering memory. It will not invoke the disconnection hook. + * @return whether the channel was open and will be closed in the future. + */ + public fun requestClose(): Boolean { + if (this.channelStatus != ChannelStatus.OPEN) { + return false + } + this.disconnectionHook.set(null) + this.channelStatus = ChannelStatus.CLOSING + this.stopReading() + this.flush() + return true + } + + /** + * Requests the channel to be closed once there's nothing more to write out, and the + * channel has been flushed. + * @return whether the channel was open and will be closed in the future. + */ + private fun internalRequestClose(): Boolean { + if (this.channelStatus != ChannelStatus.OPEN) { + return false + } + this.channelStatus = ChannelStatus.CLOSING + this.stopReading() + this.flush() + return true + } + + /** + * Polls one incoming game message from the queue, or null if none exists. + */ + private fun pollIncomingMessage(): IncomingGameMessage? = incomingMessageQueue.poll() + + /** + * Resets the incoming message counter and resumes reading. + */ + private fun onPollComplete() { + counter.reset() + resumeReading() + } + + /** + * Marks the login transition as complete, meaning we can now write out any packets that + * were queued up until now. + * This process is necessary as of Netty 4.2, specifically [this](https://github.com/netty/netty/pull/14705) PR. + * + * Quote: + * > **This also means that some code now moves from the executor of the target context, to the executor of the + * calling context. This can create different behaviors from Netty 4.1, if the pipeline has multiple handlers, is + * modified by the handlers during the call, and the handlers use child-executors.** + * + * Due to the underlying changes in Netty, it is no longer safe to queue packets up, switch pipeline + * and queue more packets up. The packets that were queued after the pipeline switch may end up processing + * and sending out first, as each pipeline handler has its own dedicated executor now, which is subject + * to the usual race condition issues. + */ + internal fun onLoginTransitionComplete() { + val flag = + this.loginTransitionStatus.getAndUpdate { old -> + old or LOGIN_TRANSITION_COMPLETE + } + if (flag and FLUSH_REQUESTED != 0) { + flush() + } + } + + /** + * Flushes any queued messages to the client, if any exist. + * The flushing process takes place in the netty event loop, thus + * the calls to this function are non-blocking and fast. + * If not all packets were written due to writability constraints, + * this function will further be re-triggered when channel writability + * turns back to true, meaning this function can be called directly + * from the netty event loop, thus the check inside it. + */ + public fun flush() { + if (this.channelStatus == ChannelStatus.CLOSED) { + return + } + if (!ctx.channel().isActive) { + triggerIdleClosing() + return + } + if (outgoingMessageQueues.all(Queue::isEmpty)) { + return + } + updateLastFlush() + // If login transition hasn't finished yet, wait. + // We do this without the write call for extra performance + if (this.loginTransitionStatus.get() and LOGIN_TRANSITION_COMPLETE == 0) { + val latest = + this.loginTransitionStatus.getAndUpdate { old -> + old or FLUSH_REQUESTED + } + // Secondary check due to race conditions; it may have changed since the preliminary check + if (latest and LOGIN_TRANSITION_COMPLETE == 0) { + return + } + } + val eventLoop = ctx.channel().eventLoop() + if (eventLoop.inEventLoop()) { + writeAndFlush() + } else { + eventLoop.execute { + writeAndFlush() + } + } + } + + /** + * Clears all the remaining incoming and outgoing messages, releasing any buffers that were wrapped + * in a byte buffer holder. + * This function should be called on logout and whenever a reconnection happens, in order + * to get rid of any messages that got written to the session, but couldn't be flushed + * out in time before the session became inactive. + */ + public fun clear() { + for (queue in outgoingMessageQueues) { + while (true) { + val next = queue.poll() ?: break + next.safeRelease() + } + } + val incomingQueue = incomingMessageQueue + while (true) { + val next = incomingQueue.poll() ?: break + next.safeRelease() + } + } + + /** + * Writes any messages that can be written based on writability to the channel. + * The message queues are processed in ascending order, with the highest + * priority being the first. If the writability turns false half-way through, + * no more messages are written out - this will be resumed when channel writability + * changes to true again. + * At the end of this function call, the channel is flushed. + */ + private fun writeAndFlush() { + val channel = ctx.channel() + categories@ for (category in GameServerProtCategory.entries) { + val queue = outgoingMessageQueues[category.id] + + // Safely discard any low priority category packets if they are disabled + if (category == GameServerProtCategory.LOW_PRIORITY_PROT && + lowPriorityCategoryPacketsDiscarded.get() + ) { + while (true) { + val next = queue.poll() ?: break + next.safeRelease() + } + continue + } + + packets@ while (true) { + if (!channel.isWritable) { + break@categories + } + val next = queue.poll() ?: break@packets + networkLog(logger) { + "Writing outgoing game packet to channel '${ctx.channel()}': $next" + } + channel.write(next, channel.voidPromise()) + } + } + if (this.channelStatus == ChannelStatus.CLOSING) { + this.channelStatus = ChannelStatus.CLOSED + trafficMonitor + .gameChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + GameDisconnectionReason.LOGOUT, + ) + channel + .writeAndFlush(Unpooled.EMPTY_BUFFER) + .addListener(ChannelFutureListener.CLOSE) + clear() + networkLog(logger) { + "Flushed outgoing game packets to channel '${ctx.channel()}', closing channel." + } + return + } + channel.flush() + networkLog(logger) { + val leftoverPackets = outgoingMessageQueues.sumOf(Queue::size) + if (leftoverPackets > 0) { + "Flushing outgoing game packets to channel " + + "'${ctx.channel()}': $leftoverPackets leftover packets remaining" + } else { + "Flushing outgoing game packets to channel ${ctx.channel()}" + } + } + } + + /** + * Sets auto-read back to true and single decode back to false. + */ + internal fun resumeReading() { + if (!ctx.channel().isOpen) { + return + } + try { + setReadStatus(stopReading = false) + } catch (e: Exception) { + logger.debug(e) { "Unable to continue reading from the channel - channel already closed." } + } + } + + /** + * Sets auto-read back to false and single decode back to true. + */ + internal fun stopReading() { + if (!ctx.channel().isOpen) { + return + } + try { + setReadStatus(stopReading = true) + } catch (e: Exception) { + logger.debug(e) { "Unable to stop reading from the channel - channel already closed." } + } + } + + /** + * Sets the auto-read and single decode status based on the input, + * if the channel is still open. + * If the single decode is set to false, netty will IMMEDIATELY stop + * decoding any more packets. Just turning auto-read to false does not + * have the same behavior, as by default, netty will read until the ctx + * is empty. This ensures that we only decode exactly up to the packet limit + * and never any more beyond that. + * If auto-read is set back to true, netty automatically calls ctx.read(), so + * we do not need to manually call this. + * @param stopReading whether to stop reading more packets, or resume reading + */ + private fun setReadStatus(stopReading: Boolean) { + val channel = ctx.channel() + // The decoder will be null if the channel has closed + val decoder = + channel.pipeline()[GameMessageDecoder::class.java] + ?: return + decoder.isSingleDecode = stopReading + channel.config().isAutoRead = !stopReading + } + + /** + * Adds an incoming message to the incoming message queue. + * Function is public to assist with testing, and should not be invoked + * by servers outside of that. + */ + public fun addIncomingMessage(incomingGameMessage: IncomingGameMessage) { + if (this.channelStatus != ChannelStatus.OPEN) return + incomingMessageQueue += incomingGameMessage + } + + /** + * Increment the message counter for the provided incoming game message, + * based on the message's category. + */ + internal fun incrementCounter(incomingGameMessage: IncomingGameMessage) { + if (this.channelStatus != ChannelStatus.OPEN) return + counter.increment(incomingGameMessage.category) + } + + /** + * Whether any of the incoming message categories are full, meaning + * no more packets should be decoded. + */ + internal fun isFull(): Boolean = counter.isFull() + + /** + * Invokes the disconnection hook if it isn't null, while also nulling out the property, + * so that it will never get invoked more than once, even if it ends up getting called from + * different threads simultaneously. + */ + private fun invokeDisconnectionHook() { + this.disconnectionHook.getAndSet(null)?.run() + } + + private enum class ChannelStatus { + OPEN, + CLOSING, + CLOSED, + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + private val limboIdleDuration: Duration = 30.seconds + private const val LOGIN_TRANSITION_COMPLETE: Int = 0x1 + private const val FLUSH_REQUESTED: Int = 0x2 + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/SessionIdGenerator.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/SessionIdGenerator.kt new file mode 100644 index 000000000..26486ad27 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/SessionIdGenerator.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.api + +/** + * A session id generator for new connections. + * By default, a secure random implementation is used. + * This session id is further passed back in the login block, and the library + * will verify to make sure the session id matches. + */ +public interface SessionIdGenerator { + /** + * Generates a new session id + * @param address in case the session id should be based on the address + */ + public fun generate(address: String): Long +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/StreamCipherProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/StreamCipherProvider.kt new file mode 100644 index 000000000..56df612e9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/StreamCipherProvider.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.api + +import net.rsprot.crypto.cipher.StreamCipher + +/** + * A provider for stream ciphers, where a new instance is allocated after each successful login. + */ +public fun interface StreamCipherProvider { + /** + * Provides a new stream cipher based on the input seed. + */ + public fun provide(seed: IntArray): StreamCipher +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/BinaryHeaderProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/BinaryHeaderProvider.kt new file mode 100644 index 000000000..0fd445a30 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/BinaryHeaderProvider.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.api.binary + +import java.text.SimpleDateFormat +import java.util.Date +import kotlin.math.min + +/** + * An interface to provide the missing parts of a binary header. + */ +public fun interface BinaryHeaderProvider { + /** + * Provides the missing partial details of a binary header, or null if a binary blob should + * not be generated for this user. + * + * Note: There are standard helpers in [BinaryHeaderProvider.Companion] to construct a RSProx-like + * file name. This can be decorated with the player name in-front of it, for example. + * + * @param playerIndex the index of the player that is logging in. This function is invoked + * right before the [net.rsprot.protocol.api.Session] object is provided to the server. This index + * allows the server to look up the player in the player-list and yield their name, for example, + * for cleaner binary files. + * @param timestamp the epoch time milliseconds when the binary header is being constructed. + * @param accountHash an SHA-256 hash of the user id and user hash. + * @return the partial binary header, or null if the binary blob should be skipped for this user. + */ + public fun provide( + playerIndex: Int, + timestamp: Long, + accountHash: ByteArray, + ): PartialBinaryHeader? + + public companion object { + private const val BINARY_EXTENSION: String = "bin" + private val FILE_NAME_DATE_FORMATTER = SimpleDateFormat("yyyyMMdd'T'HHmmss") + + @JvmStatic + public fun fileName( + timestamp: Long, + accountHash: ByteArray, + ): String { + return "${fileNameWithoutSuffix(timestamp, accountHash)}.$BINARY_EXTENSION" + } + + @JvmStatic + @OptIn(ExperimentalStdlibApi::class) + public fun fileNameWithoutSuffix( + timestamp: Long, + accountHash: ByteArray, + ): String { + val date = Date(timestamp) + val formattedDate = FILE_NAME_DATE_FORMATTER.format(date) + val hexHash = accountHash.toHexString(HexFormat.Default) + val shortHash = hexHash.substring(0, min(7, hexHash.length)) + return "$formattedDate-$shortHash" + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/PartialBinaryHeader.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/PartialBinaryHeader.kt new file mode 100644 index 000000000..0dbaaee2f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/PartialBinaryHeader.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.api.binary + +import java.nio.file.Path + +/** + * A partial binary header, containing all the data that RSProt cannot gather on its own + * and needs the server to provide. + * @property path the path into which the .bin file will be written. + * @property worldId the id of the world + * @property worldFlags the world flags/properties + * @property worldLocation the country of the world + * @property worldHost the host address of the world + * @property worldActivity the activity name of the world + * @property clientName the name of the client to use + */ +public data class PartialBinaryHeader( + public val path: Path, + public val worldId: Int, + public val worldFlags: Int, + public val worldLocation: Int, + public val worldHost: String, + public val worldActivity: String, + public val clientName: String, +) diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAppendWriter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAppendWriter.kt new file mode 100644 index 000000000..d4216df32 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAppendWriter.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.api.binary.writer + +import net.rsprot.protocol.binary.BinaryBlob + +/** + * A binary blob writer that appends to an existing file. This writer is not atomic, however, a power outage + * will not make it completely unusable, as it would only corrupt the file ending, allowing the + * earlier sections to still be transcribed. + * + * Note that this writer does not make the necessary directories if they don't yet exist, + * this is up to the user to ensure (ideally only once on server startup, rather than per operation here). + * + * This writer is preferred to [BinaryBlobAtomicReplacementWriter] as it avoids keeping huge slabs + * of data in memory for extended periods of time. With 2,000 players, it is reasonable to expect + * the binary blobs to occupy up to ~10gb of heap under these buffers. The appending writer avoids + * this by taking a snapshot of the buffer and resetting the pointers, avoiding the buffer + * from ever-growing too large. This comes at the cost of losing atomicity. + * @property retryCount the number of times to re-attempt to write the binary blob in case of + * an error. If the count is or reaches zero, the error will be re-thrown and should be + * handled by the user. + */ +public class BinaryBlobAppendWriter( + private val retryCount: Int = 2, +) : BinaryBlobWriter { + override fun write(blob: BinaryBlob): Boolean { + val array = + blob.stream.incrementalSnapshotOrNull() + ?: return false + append(blob.header.path, array, retryCount) + return true + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAtomicReplacementWriter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAtomicReplacementWriter.kt new file mode 100644 index 000000000..34e8abffd --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobAtomicReplacementWriter.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.api.binary.writer + +import net.rsprot.protocol.binary.BinaryBlob + +/** + * A binary blob writer that always writes the full stream, starting with the header and ending with + * the last packet, no matter how many times it has previously been written. + * It first writes into a temporary file in the same directory, with a '.' prefix, then attempts + * to atomic-move it into the real path. + * + * Note that this writer does not make the necessary directories if they don't yet exist, + * this is up to the user to ensure (ideally only once on server startup, rather than per operation here). + * + * @property retryCount the number of times to re-attempt to write the binary blob in case of + * an error. If the count is or reaches zero, the error will be re-thrown and should be + * handled by the user. + */ +public class BinaryBlobAtomicReplacementWriter( + private val retryCount: Int = 2, +) : BinaryBlobWriter { + override fun write(blob: BinaryBlob): Boolean { + val array = + blob.stream.fullSnapshotOrNull() + ?: return false + tempWriteAndAtomicReplace(blob.header.path, array, retryCount) + return true + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobWriter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobWriter.kt new file mode 100644 index 000000000..1beaebfce --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/BinaryBlobWriter.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.api.binary.writer + +import net.rsprot.protocol.binary.BinaryBlob +import kotlin.jvm.Throws + +/** + * A functional interface for binary blob writers. + */ +public fun interface BinaryBlobWriter { + /** + * Attempts to write the [blob] at the path specified by [net.rsprot.protocol.binary.BinaryHeader.path]. + * + * @return true if the write succeeded, false if there's a lock on it. A lock will temporarily be + * acquired when a packet group is being written, as it has to update the payload length retroactively + * as it writes out all the contents of the group. + * Note that this function can throw errors regarding file writing and should be gracefully handled. + */ + @Throws(Throwable::class) + public fun write(blob: BinaryBlob): Boolean +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/PathWriteHelpers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/PathWriteHelpers.kt new file mode 100644 index 000000000..adcec1be8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/binary/writer/PathWriteHelpers.kt @@ -0,0 +1,79 @@ +package net.rsprot.protocol.api.binary.writer + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import kotlin.io.path.Path + +/** + * Writes the [array] into a file named ".path", then atomic-moves (replaces) it over to "path". + * @param path the path to write the final file to. + * @param array the data to write into the file. + * @param retryCount the number of retry attempts to go through when an exception is thrown. + * If the count reaches zero, the exception is rethrown to the caller. + */ +public fun tempWriteAndAtomicReplace( + path: Path, + array: ByteArray, + retryCount: Int, +) { + try { + val file = path.toFile() + val parent = path.parent + val tempFileName = ".${file.name}" + val tempPath = + if (parent == null) { + val root = path.root + if (root == null) { + Path(tempFileName) + } else { + root.resolve(tempFileName) + } + } else { + parent.resolve(tempFileName) + } + Files.write( + tempPath, + array, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.SYNC, + ) + Files.move(tempPath, path, StandardCopyOption.ATOMIC_MOVE) + } catch (t: Throwable) { + if (retryCount > 0) { + return tempWriteAndAtomicReplace(path, array, retryCount - 1) + } + throw t + } +} + +/** + * Appends the [array] onto the [path] if some data already exists there, otherwise creates a new file with it. + * This operation is NOT atomic, and it is possible for a power outage to corrupt the end of the file. + * It will however not corrupt the earlier sections of it, keeping it still relatively usable, + * as the file is a continuous stream of packets. + * @param retryCount the number of retry attempts to go through when an exception is thrown. + * If the count reaches zero, the exception is rethrown to the caller. + */ +public fun append( + path: Path, + array: ByteArray, + retryCount: Int, +) { + try { + Files.write( + path, + array, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + StandardOpenOption.SYNC, + ) + } catch (t: Throwable) { + if (retryCount > 0) { + return append(path, array, retryCount - 1) + } + throw t + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/bootstrap/BootstrapBuilder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/bootstrap/BootstrapBuilder.kt new file mode 100644 index 000000000..33f645bf2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/bootstrap/BootstrapBuilder.kt @@ -0,0 +1,433 @@ +package net.rsprot.protocol.api.bootstrap + +import com.github.michaelbull.logging.InlineLogger +import io.netty.bootstrap.ServerBootstrap +import io.netty.buffer.ByteBufAllocator +import io.netty.channel.ChannelOption +import io.netty.channel.EventLoopGroup +import io.netty.channel.IoHandlerFactory +import io.netty.channel.MultiThreadIoEventLoopGroup +import io.netty.channel.ServerChannel +import io.netty.channel.WriteBufferWaterMark +import io.netty.channel.epoll.Epoll +import io.netty.channel.epoll.EpollIoHandler +import io.netty.channel.epoll.EpollServerSocketChannel +import io.netty.channel.kqueue.KQueue +import io.netty.channel.kqueue.KQueueIoHandler +import io.netty.channel.kqueue.KQueueServerSocketChannel +import io.netty.channel.nio.NioIoHandler +import io.netty.channel.socket.nio.NioServerSocketChannel +import io.netty.channel.uring.IoUring +import io.netty.channel.uring.IoUringIoHandler +import io.netty.channel.uring.IoUringServerSocketChannel +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder.EventLoopGroupType.EPOLL +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder.EventLoopGroupType.IOURING +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder.EventLoopGroupType.KQUEUE +import net.rsprot.protocol.api.bootstrap.BootstrapBuilder.EventLoopGroupType.NIO +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import java.text.NumberFormat +import java.util.function.Consumer +import kotlin.math.max + +/** + * A bootstrap builder responsible for generating the Netty bootstrap + */ +public class BootstrapBuilder { + /** + * An enum of possible event loop group types which one could choose between. + * @property IOURING an asynchronous event loop group for the Linux kernels. + * @property EPOLL an event loop group for the Linux kernels. + * @property KQUEUE an event loop group for BSD (FreeBSD / OpenBSD) and + * Darwin (Mac OS X / iOS) kernels. + * @property NIO an event loop group by the JVM, available on all platforms. + */ + public enum class EventLoopGroupType { + IOURING, + EPOLL, + KQUEUE, + NIO, + } + + private var allocator: ByteBufAllocator? = null + private var bossThreadCount: Int? = null + private var childThreadCount: Int? = null + private var soRcvBufSize: Int? = null + private var soSndBufSize: Int? = null + private var writeBufferWatermarkLow: Int? = null + private var writeBufferWatermarkHigh: Int? = null + private var tcpNoDelay: Boolean? = null + private var soBacklog: Int? = null + private var soReuseAddress: Boolean? = null + private var eventLoopGroupTypes: Array? = null + private var configureBootstrapExtra: Consumer? = null + + /** + * Sets the default byte buffer allocator that is used throughout RSProt for incoming + * and outgoing messages. + * The default value is [ByteBufAllocator.DEFAULT], which boils down to a pooled + * direct byte buffer allocator by default, as long as it is available, otherwise + * the pooled heap byte buffer allocator is used. It is possible to switch the underlying + * type via system properties, so the end result may not be as described. + * @param alloc the byte buffer allocator used for all buffers. + */ + public fun allocator(alloc: ByteBufAllocator): BootstrapBuilder { + this.allocator = alloc + return this + } + + /** + * Sets the boss thread count to the specified [threadCount]. If the [threadCount] is 0, + * Netty will use a number equal to the number of physical CPU threads the server has. + * + * The default value for boss thread count is 1. It should be noted that there isn't any + * benefit to increasing the boss thread count, as this merely accepts new incoming connections, + * accepting too much at once might overload the child threads and cause more harm anyway. + * + * @param threadCount the number of threads to use. + */ + public fun bossThreadCount(threadCount: Int): BootstrapBuilder { + this.bossThreadCount = threadCount + return this + } + + /** + * Sets the child thread count to the specified [threadCount]. If the [threadCount] is 0, + * Netty will use a number equal to the number of physical CPU threads the server has. + * + * The default value for child thread count is `physicalThreadCount - 2`, with a minimum of 1. + * This means at least one CPU core will be free to handle the rest of the processes, + * which should keep the application usable even if it is getting attacked by denial of service + * type attacks. Utilizing the entire CPU could essentially turn the application unusable and + * cause more harm than good. The general logic here is that if Netty requires every single + * thread your server has to offer, your server is going to die anyhow, so it's best to try + * and keep it at least usable. + * + * @param threadCount the number of threads to use. + */ + public fun childThreadCount(threadCount: Int): BootstrapBuilder { + this.childThreadCount = threadCount + return this + } + + /** + * Sets the socket receive buffer size at kernel level, telling the system what kind of + * buffers to use for incoming traffic. + * The default value is 65536 bytes. + * + * @param numBytes the number of bytes used for the buffer at kernel level. + */ + public fun socketReceiveBufferSize(numBytes: Int): BootstrapBuilder { + this.soRcvBufSize = numBytes + return this + } + + /** + * Sets the socket send buffer size at kernel level, telling the system what kind of + * buffers to use for outgoing traffic. + * The default value is 65536 bytes. + * + * @param numBytes the number of bytes used for the buffer at kernel level. + */ + public fun socketSendBufferSize(numBytes: Int): BootstrapBuilder { + this.soSndBufSize = numBytes + return this + } + + /** + * Sets the buffer watermarks for a given channel, indicating when the server should stop + * trying to write more bytes into the channel and when to continue. + * + * An important note is that watermarks are based off of [io.netty.channel.MessageSizeEstimator] + * implementations, not the underlying packet itself. For any unknown messages (anything that + * isn't a [io.netty.buffer.ByteBuf] or [io.netty.buffer.ByteBufHolder]), the size is estimated + * to be 8 bytes, this can easily become problematic if the actual message holds onto hundreds + * of kilobytes, for example. Starting in revision 225, RSProt will accurately estimate the size + * of messages. For any older revisions, anything that holds a byte buffer, which is most of the + * larger messages, will be implemented via a [io.netty.buffer.DefaultByteBufHolder], ensuring + * that the big problem cases will not cause major miscalculations. Anything else, however, + * will just be assumed to be 8 bytes, which likely is a good estimate anyhow. + * + * The largest possible outgoing message, as of revision 225, is roughly 630kb, via a JS5 group + * for the client background. + * + * @param numLowBytes the number of bytes waiting to be flushed at which the channel + * becomes writable again as dictated by [io.netty.channel.Channel.isWritable]. + * The default value for the low watermark is 524,288 bytes. + * @param numHighBytes the number of bytes waiting to be flushed at which the channel + * becomes **un-writable**. It should be noted that this is merely a suggestion, not enforced + * by Netty. The implementation (in this case, RSProt) must be responsible for not trying + * to write any more bytes into the channel after that point. Netty will accept any number + * of bytes forced into it, even if [io.netty.channel.Channel.isWritable] is returning false, + * until inevitably running out of memory. + * The default value for high watermark is 2,097,152 bytes. + */ + public fun writeBufferWatermark( + numLowBytes: Int, + numHighBytes: Int, + ): BootstrapBuilder { + this.writeBufferWatermarkLow = numLowBytes + this.writeBufferWatermarkHigh = numHighBytes + return this + } + + /** + * Sets the value of TCP_NODELAY socket option. If [value] is true, Nagle's algorithm will + * be disabled, which results in smaller delays between writes, but more writes overall. + * While Nagle's algorithm used to be helpful in the distant past, networking nowadays + * has come a long way to where this simply degrades the overall performance. + * The default value is true, meaning Nagle's algorithm is disabled. + * + * [Nagle's Algorithm](https://en.wikipedia.org/wiki/Nagle%27s_algorithm) + * + * @param value whether to disable TCP_NODELAY optimization. + */ + public fun tcpNoDelay(value: Boolean): BootstrapBuilder { + this.tcpNoDelay = value + return this + } + + /** + * Sets the maximum number of TCP connections that can be queued up before the server + * actually accepts the connection. Note that the default value is whatever the OS' kernel + * has by default. Increasing it here alone will NOT increase the backlog capacity alone. + * You must also modify the kernel to allow a higher capacity alongside it. + * + * On Linux, backlog can be configured via `somaxconn` and `tcp_max_syn_backlog`. + * `somaxconn` is the maximum allowed backlow, often defaulting to 128. + * `tcp_max_syn_backlog` is the maximum allowed half-open connections while the + * syn-handshake takes place. The default is usually either 1024 or 4096. + * + * @param value the maximum number of connections to queue up at the kernel level, + * limited to the kernel's own configuration. The default on our end is 4096, although + * most kernels limit it to a smaller value, such as 128. The smallest of the two is picked. + */ + public fun soBacklog(value: Int): BootstrapBuilder { + this.soBacklog = value + return this + } + + /** + * Allows for the socket to bind to the same ip & port if the previous socket is in + * TIME_WAIT state, which happens for a short period after a socket is shut down, to + * capture any stray packets. The default value is true. + * + * @param value whether to enable SO_REUSEADDR. + */ + public fun reuseAddress(value: Boolean): BootstrapBuilder { + this.soReuseAddress = value + return this + } + + /** + * Sets a priority array of event loop group types to use, preferring the ones at + * the front of the array over those at the back. Each type in the array will be + * tested one by one, until an event loop group is available. Omitting a group type + * means it will not be used altogether. + * The default priority order is [EventLoopGroupType.IOURING] -> [EventLoopGroupType.EPOLL] -> + * [EventLoopGroupType.KQUEUE] -> [EventLoopGroupType.NIO]. + * + * @param types the event loop group types to try, starting with the front of + * the array. + */ + public fun eventLoopGroupTypes(vararg types: EventLoopGroupType): BootstrapBuilder { + this.eventLoopGroupTypes = types + return this + } + + /** + * Allows the server to re-configure the bootstrap on-top of the offered settings here, + * or override any pre-existing settings. + * Note that [ChannelOption.AUTO_READ] should not be reconfigured under any circumstances, + * as the logic flow of the network depends on it being default-disabled. + * + * @param block the block invoked on the server bootstrap at the very end. + */ + @JvmSynthetic + public fun configureBootstrapExtra(block: (ServerBootstrap) -> Unit): BootstrapBuilder { + this.configureBootstrapExtra = Consumer { block(it) } + return this + } + + /** + * Allows the server to re-configure the bootstrap on-top of the offered settings here, + * or override any pre-existing settings. + * Note that [ChannelOption.AUTO_READ] should not be reconfigured under any circumstances, + * as the logic flow of the network depends on it being default-disabled. + * + * @param consumer the consumer invoked on the server bootstrap at the very end. + */ + public fun configureBootstrapExtra(consumer: Consumer): BootstrapBuilder { + this.configureBootstrapExtra = consumer + return this + } + + private fun getEventLoopGroupTypes(): Array { + val types = this.eventLoopGroupTypes + if (types != null) { + return types + } + return arrayOf( + IOURING, + EPOLL, + KQUEUE, + NIO, + ) + } + + private fun determineBossThreadCount(): Int = this.bossThreadCount ?: 1 + + private fun determineChildThreadCount(): Int { + val overwrittenCount = this.childThreadCount + if (overwrittenCount != null) { + return overwrittenCount + } + // The default value for child thread count is `physicalThreadCount - 2`, with a minimum of 4. + val cores = Runtime.getRuntime().availableProcessors() + val physicalThreads = cores * 2 + return max(1, physicalThreads - 2) + } + + private data class BuildEventLoopGroupsResult( + val type: EventLoopGroupType, + val factory: IoHandlerFactory, + val bossGroup: EventLoopGroup, + val childGroup: EventLoopGroup, + ) + + private fun buildEventLoopGroups( + bossThreadCount: Int, + childThreadCount: Int, + groupTypes: Array, + ): BuildEventLoopGroupsResult { + for (type in groupTypes) { + try { + when (type) { + IOURING -> { + if (!IoUring.isAvailable()) { + continue + } + val factory = IoUringIoHandler.newFactory() + val boss = MultiThreadIoEventLoopGroup(bossThreadCount, factory) + val child = MultiThreadIoEventLoopGroup(childThreadCount, factory) + return BuildEventLoopGroupsResult(type, factory, boss, child) + } + + EPOLL -> { + if (!Epoll.isAvailable()) { + continue + } + val factory = EpollIoHandler.newFactory() + val boss = MultiThreadIoEventLoopGroup(bossThreadCount, factory) + val child = MultiThreadIoEventLoopGroup(childThreadCount, factory) + return BuildEventLoopGroupsResult(type, factory, boss, child) + } + + KQUEUE -> { + if (!KQueue.isAvailable()) { + continue + } + val factory = KQueueIoHandler.newFactory() + val boss = MultiThreadIoEventLoopGroup(bossThreadCount, factory) + val child = MultiThreadIoEventLoopGroup(childThreadCount, factory) + return BuildEventLoopGroupsResult(type, factory, boss, child) + } + + NIO -> { + val factory = NioIoHandler.newFactory() + val boss = MultiThreadIoEventLoopGroup(bossThreadCount, factory) + val child = MultiThreadIoEventLoopGroup(childThreadCount, factory) + return BuildEventLoopGroupsResult(type, factory, boss, child) + } + } + } catch (t: Throwable) { + // Notify the user of an error if one does happen, which is possible even if + // the `isAvailable()` function returns true in some edge cases. This typically + // means some obscure bug, however, that users might want to look into. + logger.error(t) { + "Unable to create $type event group type." + } + } + } + throw IllegalStateException("No event loop groups are available in ${groupTypes.contentDeepToString()}") + } + + private fun determineSocketChannel(type: EventLoopGroupType): Class = + when (type) { + IOURING -> IoUringServerSocketChannel::class.java + EPOLL -> EpollServerSocketChannel::class.java + KQUEUE -> KQueueServerSocketChannel::class.java + NIO -> NioServerSocketChannel::class.java + } + + /** + * Builds the server bootstrap based on the criteria given through the builder. + */ + public fun build(estimator: OutgoingMessageSizeEstimator): ServerBootstrap { + val bootstrap = ServerBootstrap() + val groupTypes = getEventLoopGroupTypes() + val bossThreadCount = determineBossThreadCount() + val childThreadCount = determineChildThreadCount() + val (type, factory, bossGroup, childGroup) = + buildEventLoopGroups( + bossThreadCount, + childThreadCount, + groupTypes, + ) + val channel = determineSocketChannel(type) + log { + "Using IO handler factory: ${factory.javaClass.simpleName} " + + "(bossThreads: $bossThreadCount, childThreads: $childThreadCount)" + } + bootstrap.group(bossGroup, childGroup) + bootstrap.channel(channel) + val formatter = NumberFormat.getIntegerInstance() + val allocator = this.allocator ?: ByteBufAllocator.DEFAULT + bootstrap.option(ChannelOption.ALLOCATOR, allocator) + bootstrap.childOption(ChannelOption.ALLOCATOR, allocator) + log { "Using byte buffer allocator: $allocator" } + bootstrap.childOption(ChannelOption.AUTO_READ, false) + log { "Auto read: disabled" } + val soRcvBufSize = this.soRcvBufSize ?: 65536 + bootstrap.childOption(ChannelOption.SO_RCVBUF, soRcvBufSize) + log { "Socket receive buffer size: ${formatter.format(soRcvBufSize)}" } + val soSndBufSize = this.soSndBufSize ?: 65536 + bootstrap.childOption(ChannelOption.SO_SNDBUF, soSndBufSize) + val soBacklog = soBacklog ?: 4096 + log { "Socket backlog: $soBacklog" } + bootstrap.option(ChannelOption.SO_BACKLOG, soBacklog) + val soReuseAddr = soReuseAddress ?: true + log { "Reuse socket address: $soReuseAddr" } + bootstrap.option(ChannelOption.SO_REUSEADDR, soReuseAddr) + log { "Socket send buffer size: ${formatter.format(soSndBufSize)}" } + val lowWatermark = this.writeBufferWatermarkLow ?: 524_288 + val highWatermark = this.writeBufferWatermarkHigh ?: 2_097_152 + bootstrap.childOption( + ChannelOption.WRITE_BUFFER_WATER_MARK, + WriteBufferWaterMark( + lowWatermark, + highWatermark, + ), + ) + log { + "Write buffer watermarks: ${formatter.format(lowWatermark)}/${formatter.format(highWatermark)}" + } + val tcpNoDelay = this.tcpNoDelay != false + bootstrap.childOption(ChannelOption.TCP_NODELAY, tcpNoDelay) + log { "Nagle's algorithm (TCP no delay): ${if (tcpNoDelay) "disabled" else "enabled"}" } + bootstrap.childOption(ChannelOption.MESSAGE_SIZE_ESTIMATOR, estimator) + val extra = this.configureBootstrapExtra + if (extra != null) { + log { "Configuring bootstrap with custom modifications" } + extra.accept(bootstrap) + } + return bootstrap + } + + private fun log(msg: () -> Any?) { + logger.debug(msg) + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/config/NetworkConfiguration.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/config/NetworkConfiguration.kt new file mode 100644 index 000000000..6a15f5b09 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/config/NetworkConfiguration.kt @@ -0,0 +1,37 @@ +package net.rsprot.protocol.api.config + +/** + * A configuration class for various knobs and toggles related to the network. + * @property incomingGamePacketBacklog the number of incoming game packets that are stored in the decoder + * and printed as part of exception logs whenever a decoder exception is hit. This allows developers + * to backtrack the packets and figure out which one caused the problems. + * The default value is 5 packets. + */ +public class NetworkConfiguration( + public val incomingGamePacketBacklog: Int, +) { + /** + * A builder class to create the network configuration instance. + */ + public class Builder { + private var incomingGamePacketBacklog: Int = 5 + + /** + * Sets the number of incoming game packets that are stored in the decoder and printed + * as part of error stack traces whenever an error in decoding occurs. + * @param num the number of packet opcodes to store, defaulting to 5. + */ + public fun setIncomingGamePacketBacklog(num: Int): Builder { + this.incomingGamePacketBacklog = num + return this + } + + /** + * Builds the network configuration instance. + */ + internal fun build(): NetworkConfiguration = + NetworkConfiguration( + incomingGamePacketBacklog, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/decoder/DecoderState.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/decoder/DecoderState.kt new file mode 100644 index 000000000..6f0cc6bbb --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/decoder/DecoderState.kt @@ -0,0 +1,10 @@ +package net.rsprot.protocol.api.decoder + +/** + * An enum containing the possible states for decoding messages from client. + */ +internal enum class DecoderState { + READ_OPCODE, + READ_LENGTH, + READ_PAYLOAD, +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/encoder/OutgoingMessageEncoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/encoder/OutgoingMessageEncoder.kt new file mode 100644 index 000000000..748106ca5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/encoder/OutgoingMessageEncoder.kt @@ -0,0 +1,560 @@ +package net.rsprot.protocol.api.encoder + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufHolder +import io.netty.buffer.Unpooled +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelOutboundHandlerAdapter +import io.netty.channel.ChannelPromise +import io.netty.handler.codec.EncoderException +import io.netty.util.ReferenceCountUtil +import net.rsprot.buffer.extensions.p1 +import net.rsprot.buffer.extensions.p2 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.Prot +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import net.rsprot.protocol.binary.BinaryStream +import net.rsprot.protocol.game.outgoing.misc.client.PacketGroupStart +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.ByteBufHolderWrapperFooterMessage +import net.rsprot.protocol.message.ByteBufHolderWrapperHeaderMessage +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.OutgoingMessage +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository +import java.util.function.Consumer +import kotlin.math.min + +/** + * A generic message encoder for all outgoing messages, including login, JS5 and game. + * @property cipher the stream cipher used to encrypt the opcodes, and in the case of + * some packets, the entire payload + * @property repository the message encoder repository containing all the encoders + */ +public abstract class OutgoingMessageEncoder : ChannelOutboundHandlerAdapter() { + protected abstract val cipher: StreamCipher + protected abstract val repository: MessageEncoderRepository<*> + protected abstract val validate: Boolean + protected abstract val estimator: OutgoingMessageSizeEstimator + protected var stream: BinaryStream? = null + + private var opcode: Int = -1 + private var constantSize: Int = Int.MIN_VALUE + private var payloadStartIndex: Int = -1 + private var payloadEndIndex: Int = -1 + + override fun write( + ctx: ChannelHandlerContext, + msg: Any, + promise: ChannelPromise, + ) { + if (msg !is OutgoingMessage) { + ctx.write(msg, promise) + return + } + try { + when (msg) { + is ByteBufHolder -> { + writeByteBufHolderMessage(ctx, msg, promise) + } + + is PacketGroupStart -> { + writePacketGroup(ctx, msg, promise) + } + + else -> { + writeRegularMessage(ctx, msg, promise) + } + } + } catch (e: EncoderException) { + throw e + } catch (t: Throwable) { + throw EncoderException(t) + } + } + + private fun pushPacket(buf: ByteBuf) { + val stream = this.stream ?: return + check(this.opcode != -1) + check(this.constantSize != Int.MIN_VALUE) + check(this.payloadStartIndex != -1) + check(this.payloadEndIndex != -1) + stream.append( + serverToClient = true, + opcode = this.opcode, + size = this.constantSize, + payload = buf.retainedSlice(payloadStartIndex, payloadEndIndex - payloadStartIndex), + ) + this.opcode = -1 + this.constantSize = -1 + this.payloadStartIndex = -1 + this.payloadEndIndex = -1 + } + + private fun pushPacketWithCallback(buf: ByteBuf): Consumer? { + val stream = this.stream ?: return null + check(this.opcode != -1) + check(this.constantSize != -1) + check(this.payloadStartIndex != -1) + check(this.payloadEndIndex != -1) + val callback = + stream.appendWithSizeCallback( + serverToClient = true, + opcode = this.opcode, + size = this.constantSize, + payload = buf.retainedSlice(payloadStartIndex, payloadEndIndex - payloadStartIndex), + ) + this.opcode = -1 + this.constantSize = -1 + this.payloadStartIndex = -1 + this.payloadEndIndex = -1 + return callback + } + + protected open fun mapOpcode(opcode: Int): Int { + return opcode + } + + private fun writePacketGroup( + ctx: ChannelHandlerContext, + msg: PacketGroupStart, + promise: ChannelPromise, + ) { + // Partition the packet groups using our message estimates. + // While the estimates may not be perfectly accurate, they account for worst-case scenarios, + // and we technically have 7kb of headroom here. + val childLists = + buildList { + var sum = 0 + var curList: MutableList = mutableListOf() + val estimatorHandle = estimator.newHandle() + for (message in msg.messages) { + val size = estimatorHandle.size(message) + // If we've hit the 32767 limit, wrap up the list and append to the next one + if (sum + size >= 32767) { + add(curList) + sum = size + curList = ArrayList() + curList += message + continue + } + + // Otherwise keep on appending here. + sum += size + curList += message + } + + if (curList.isNotEmpty()) { + add(curList) + } + } + for ((index, list) in childLists.withIndex()) { + // Fulfill the promise on the last write operation here + val promiseToUse = if (index == childLists.size - 1) promise else ctx.voidPromise() + writeRegularMessage(ctx, PacketGroupStart(list), promiseToUse) + } + } + + private fun writeRegularMessage( + ctx: ChannelHandlerContext, + msg: OutgoingMessage, + promise: ChannelPromise, + ) { + var buf: ByteBuf? = null + try { + try { + buf = allocateBuffer(ctx, msg) + encode(ctx, msg, buf) + } finally { + ReferenceCountUtil.release(msg) + } + if (buf != null) { + if (buf.isReadable) { + ctx.write(buf, promise) + } else { + buf.release() + ctx.write(Unpooled.EMPTY_BUFFER, promise) + } + } + buf = null + } finally { + buf?.release() + } + } + + private fun writeByteBufHolderMessage( + ctx: ChannelHandlerContext, + msg: T, + promise: ChannelPromise, + ) where T : OutgoingMessage, T : ByteBufHolder { + if (msg.refCnt() <= 0) { + logger.warn { + "Unable to write bytebuf holder message as it has been released: $msg" + } + return + } + + val payload = writePacketHeader(ctx, msg) + val bufHolderContent = msg.content().slice() + payload?.writeBytes(bufHolderContent.slice()) + // If there are trailing bytes, we need to encode and write those as well + if (msg is ByteBufHolderWrapperFooterMessage) { + // If the byte buf holder wrapper is a footer, use void promise as we don't + // want to complete the promise until the last bytes of this packet have been written, + // which would be represented by the footer + if (bufHolderContent.isReadable) { + ctx.write(bufHolderContent, ctx.voidPromise()) + } else { + bufHolderContent.release() + ctx.write(Unpooled.EMPTY_BUFFER, ctx.voidPromise()) + } + + var footer: ByteBuf? = null + try { + footer = allocateBuffer(ctx, msg.nonByteBufHolderSize()) + encodePayload(msg, footer) + payload?.writeBytes(footer.slice()) + if (footer.isReadable) { + ctx.write(footer, promise) + } else { + footer.release() + ctx.write(Unpooled.EMPTY_BUFFER, promise) + } + footer = null + } finally { + footer?.release() + } + } else { + if (bufHolderContent.isReadable) { + ctx.write(bufHolderContent, promise) + } else { + bufHolderContent.release() + ctx.write(Unpooled.EMPTY_BUFFER, promise) + } + } + if (payload != null) { + this.payloadStartIndex = payload.readerIndex() + this.payloadEndIndex = payload.writerIndex() + try { + pushPacket(payload) + } finally { + payload.release() + } + } + } + + private fun writePacketHeader( + ctx: ChannelHandlerContext, + msg: T, + ): ByteBuf? where T : OutgoingMessage, T : ByteBufHolder { + val encoder = repository.getEncoder(msg::class.java) + val prot = encoder.prot + val sourceOpcode = prot.opcode + this.opcode = sourceOpcode + this.constantSize = prot.size + val opcode = mapOpcode(sourceOpcode) + var headerSize = if (opcode >= 0x80) Short.SIZE_BYTES else Byte.SIZE_BYTES + when (prot.size) { + Prot.VAR_BYTE -> headerSize += Byte.SIZE_BYTES + Prot.VAR_SHORT -> headerSize += Short.SIZE_BYTES + } + if (msg is ByteBufHolderWrapperHeaderMessage) { + headerSize += msg.nonByteBufHolderSize() + } + var buf: ByteBuf? = null + try { + buf = ctx.alloc().ioBuffer(headerSize) + pSmart1Or2Enc(buf, opcode) + var bytes = msg.content().readableBytes() + if (msg is ByteBufHolderWrapperHeaderMessage) { + bytes += msg.nonByteBufHolderSize() + } + if (msg is ByteBufHolderWrapperFooterMessage) { + bytes += msg.nonByteBufHolderSize() + } + when (prot.size) { + Prot.VAR_BYTE -> buf.p1(bytes) + Prot.VAR_SHORT -> buf.p2(bytes) + } + val payloadStartIndex = buf.writerIndex() + if (msg is ByteBufHolderWrapperHeaderMessage) { + encodePayload(msg, buf) + } + val payloadEndIndex = buf.writerIndex() + val payloadSlice = + if (this.stream != null) { + buf.copy( + payloadStartIndex, + payloadEndIndex - payloadStartIndex, + ) + } else { + null + } + ctx.write(buf, ctx.voidPromise()) + buf = null + onMessageWritten(ctx, sourceOpcode, bytes) + return payloadSlice + } finally { + buf?.release() + } + } + + private fun encodePayload( + msg: OutgoingMessage, + out: ByteBuf, + ) { + val encoder = repository.getEncoder(msg::class.java) + encoder.encode( + cipher, + out.toJagByteBuf(), + msg, + ) + } + + protected open fun encode( + ctx: ChannelHandlerContext, + msg: OutgoingMessage, + out: ByteBuf, + ) { + val startMarker = out.writerIndex() + val encoder = repository.getEncoder(msg::class.java) + val prot = encoder.prot + val sourceOpcode = prot.opcode + val isPacketGroup = msg is PacketGroupStart + this.opcode = sourceOpcode + this.constantSize = prot.size + val opcode = mapOpcode(sourceOpcode) + if (encoder.encryptedPayload) { + pSmart1Or2Enc(out, opcode) + } else { + // Write a temporary value for the opcode first + // We cannot immediately write the real stream-cipher modified opcode + // as that alters the stream cipher's own state + if (opcode < 0x80) { + out.p1(0) + } else { + out.p2(0) + } + } + + val sizeMarker = + when (prot.size) { + Prot.VAR_BYTE -> { + out.p1(0) + out.writerIndex() - 1 + } + Prot.VAR_SHORT -> { + out.p2(0) + out.writerIndex() - 2 + } + else -> -1 + } + + val payloadMarker = out.writerIndex() + this.payloadStartIndex = payloadMarker + encoder.encode( + cipher, + out.toJagByteBuf(), + msg, + ) + val endMarker = out.writerIndex() + this.payloadEndIndex = endMarker + val callback = + if (isPacketGroup) { + pushPacketWithCallback(out) + } else { + pushPacket(out) + null + } + + if (encoder.encryptedPayload) { + // Encrypt the entire buffer with a stream cipher + for (i in payloadMarker.. { + if (validate) { + if (length !in 0..MAX_UBYTE_PAYLOAD_SIZE) { + out.writerIndex(startMarker) + logger.warn { + "Server prot $prot length out of bounds; " + + "expected 0..255, received $length; message: $msg" + } + return + } + } + out.p1(length) + } + Prot.VAR_SHORT -> { + if (validate) { + if (length !in 0..MAX_USHORT_PAYLOAD_SIZE) { + out.writerIndex(startMarker) + logger.warn { + "Server prot $prot length out of bounds; " + + "expected 0..40_000, received $length; message: $msg" + } + return + } + } + out.p2(length) + } + } + out.writerIndex(endMarker) + } else if (validate) { + val length = endMarker - payloadMarker + if (length != prot.size) { + out.writerIndex(startMarker) + logger.warn { + "Server prot $prot length out of bounds; " + + "expected 0..40_000, received $length; message: $msg" + } + return + } + } + onMessageWritten(ctx, sourceOpcode, endMarker - payloadMarker) + if (!encoder.encryptedPayload) { + out.writerIndex(startMarker) + pSmart1Or2Enc(out, opcode) + out.writerIndex(endMarker) + } + // Special exception here, as this packet essentially encodes all the children as well + if (isPacketGroup) { + msg as PacketGroupStart + for (sub in msg.messages) { + try { + if (sub is ByteBufHolder) { + writeChildByteBufHolderMessage(sub, out) + } else { + encode(ctx, sub, out) + } + } finally { + ReferenceCountUtil.release(sub) + } + } + val finalMarker = out.writerIndex() + val written = finalMarker - endMarker + if (written > 40000) { + throw IllegalStateException( + "PacketGroupStart message too long: $written bytes, " + + "${msg.messages.size} child messages.", + ) + } + // Lastly, update the actual number of bytes that the packet group start contained + out.writerIndex(payloadMarker) + // Note that packet group start reads it as a signed short in the client, so we should + // cap it. + val packetGroupSize = min(32767, written) + out.p2(packetGroupSize) + // Trigger the callback to update the packet group size in our .bin file, if one exists + callback?.accept(packetGroupSize) + out.writerIndex(finalMarker) + } + } + + private fun writeChildByteBufHolderMessage( + message: T, + buf: ByteBuf, + ) where T : ByteBufHolder, T : OutgoingMessage { + if (message.refCnt() <= 0) { + logger.warn { + "Unable to write bytebuf holder message as it has been released: $message" + } + return + } + + val encoder = repository.getEncoder(message::class.java) + val prot = encoder.prot + val sourceOpcode = prot.opcode + this.opcode = sourceOpcode + this.constantSize = prot.size + val opcode = mapOpcode(sourceOpcode) + pSmart1Or2Enc(buf, opcode) + var bytes = message.content().readableBytes() + if (message is ByteBufHolderWrapperHeaderMessage) { + bytes += message.nonByteBufHolderSize() + } + if (message is ByteBufHolderWrapperFooterMessage) { + bytes += message.nonByteBufHolderSize() + } + when (prot.size) { + Prot.VAR_BYTE -> buf.p1(bytes) + Prot.VAR_SHORT -> buf.p2(bytes) + } + this.payloadStartIndex = buf.writerIndex() + if (message is ByteBufHolderWrapperHeaderMessage) { + encodePayload(message, buf) + } + + val bufHolderContent = message.content().slice() + // If there are trailing bytes, we need to encode and write those as well + + if (bufHolderContent.isReadable) { + buf.writeBytes(bufHolderContent) + } + if (message is ByteBufHolderWrapperFooterMessage) { + encodePayload(message, buf) + } + this.payloadEndIndex = buf.writerIndex() + pushPacket(buf) + } + + public open fun onMessageWritten( + ctx: ChannelHandlerContext, + opcode: Int, + payloadSize: Int, + ) { + } + + /** + * Writes a byte or short for the opcode with all the bytes + * encrypted using the stream cipher provided. + * The name of this function is from a leak. + */ + private fun pSmart1Or2Enc( + out: ByteBuf, + opcode: Int, + ) { + if (opcode < 0x80) { + out.p1(opcode + cipher.nextInt()) + } else { + out.p1((opcode ushr 8 or 0x80) + cipher.nextInt()) + out.p1((opcode and 0xFF) + cipher.nextInt()) + } + } + + protected fun allocateBuffer( + ctx: ChannelHandlerContext, + msg: OutgoingMessage, + ): ByteBuf { + val size = estimator.newHandle().size(msg) + return ctx.alloc().ioBuffer(size) + } + + private fun allocateBuffer( + ctx: ChannelHandlerContext, + cap: Int, + ): ByteBuf { + return ctx.alloc().ioBuffer(cap) + } + + private companion object { + private const val MAX_USHORT_PAYLOAD_SIZE: Int = 40_000 + private const val MAX_UBYTE_PAYLOAD_SIZE: Int = 255 + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameDisconnectionReason.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameDisconnectionReason.kt new file mode 100644 index 000000000..0a781cf8e --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameDisconnectionReason.kt @@ -0,0 +1,7 @@ +package net.rsprot.protocol.api.game + +public enum class GameDisconnectionReason { + LOGOUT, + EXCEPTION, + IDLE, +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageDecoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageDecoder.kt new file mode 100644 index 000000000..cd3ab7d24 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageDecoder.kt @@ -0,0 +1,197 @@ +package net.rsprot.protocol.api.game + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.ByteToMessageDecoder +import io.netty.handler.codec.DecoderException +import net.rsprot.buffer.extensions.g1 +import net.rsprot.buffer.extensions.g2 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.Prot +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.Session +import net.rsprot.protocol.api.decoder.DecoderState +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.binary.BinaryStream +import net.rsprot.protocol.channel.binaryStreamOrNull +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository + +/** + * A decoder for game messages, one that respects the limitations set in place + * for incoming game messages to stop decoding after a specific threshold. + * Furthermore, this will discard any payload of a packet if no consumer + * has been registered, avoiding the creation of further garbage in the form + * of decoded messages or buffer slices. + */ +@Suppress("DuplicatedCode") +public class GameMessageDecoder( + public val networkService: NetworkService, + private val session: Session, + private val streamCipher: StreamCipher, + oldSchoolClientType: OldSchoolClientType, +) : ByteToMessageDecoder() { + private val decoders: MessageDecoderRepository = + networkService + .decoderRepositories + .gameMessageDecoderRepositories[oldSchoolClientType] + + private var state: DecoderState = DecoderState.READ_OPCODE + private lateinit var decoder: MessageDecoder<*> + private var opcode: Int = -1 + private var length: Int = 0 + + private val previousPackets: IntArray = + IntArray(networkService.configuration.incomingGamePacketBacklog) { + -1 + } + private var previousPacketIndex: Int = 0 + private var stream: BinaryStream? = null + + private fun invalidOpcodeException(): Nothing = + throw IllegalStateException("Invalid opcode received! Previous packets: ${buildPreviousPacketLog()}") + + private fun buildPreviousPacketLog(): String = + buildString { + val previousPackets = this@GameMessageDecoder.previousPackets + val previousPacketIndex = (this@GameMessageDecoder.previousPacketIndex - 1) % previousPackets.size + for (i in previousPacketIndex downTo 0) { + append(previousPackets[i]).append(", ") + } + for (i in previousPackets.size - 1 downTo (previousPacketIndex + 1)) { + append(previousPackets[i]).append(", ") + } + delete(length - 2, length) + } + + private fun mapOpcode(opcode: Int): Int { + val mapper = networkService.clientToServerOpcodeMapper ?: return opcode + return mapper.decode(opcode) + } + + override fun handlerAdded(ctx: ChannelHandlerContext) { + this.stream = ctx.channel().binaryStreamOrNull() + } + + override fun decode( + ctx: ChannelHandlerContext, + input: ByteBuf, + out: MutableList, + ) { + if (state == DecoderState.READ_OPCODE) { + if (!input.isReadable) { + return + } + this.opcode = mapOpcode((input.g1() - streamCipher.nextInt()) and 0xFF) + this.previousPackets[this.previousPacketIndex++ % this.previousPackets.size] = this.opcode + val decoderOrNull = decoders.getDecoderOrNull(opcode) + if (decoderOrNull == null) { + invalidOpcodeException() + } + this.decoder = decoderOrNull + this.length = this.decoder.prot.size + state = + if (this.length >= 0) { + DecoderState.READ_PAYLOAD + } else { + DecoderState.READ_LENGTH + } + } + + if (state == DecoderState.READ_LENGTH) { + when (length) { + Prot.VAR_BYTE -> { + if (!input.isReadable(Byte.SIZE_BYTES)) { + return + } + this.length = input.g1() + } + + Prot.VAR_SHORT -> { + if (!input.isReadable(Short.SIZE_BYTES)) { + return + } + this.length = input.g2() + } + + else -> { + throw IllegalStateException( + "Invalid length: $length of opcode $opcode, " + + "previous packets: ${buildPreviousPacketLog()}", + ) + } + } + state = DecoderState.READ_PAYLOAD + } + + if (state == DecoderState.READ_PAYLOAD) { + if (!input.isReadable(length)) { + return + } + if (length > RSProtFlags.singleVarShortPacketMaxAcceptedLength) { + throw DecoderException( + "Opcode $opcode exceeds the natural maximum allowed length in OldSchool: " + + "$length > ${RSProtFlags.singleVarShortPacketMaxAcceptedLength}, " + + "previous packets: ${buildPreviousPacketLog()}", + ) + } + this.stream?.append( + serverToClient = false, + opcode = this.opcode, + size = this.decoder.prot.size, + payload = input.retainedSlice(input.readerIndex(), length), + ) + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .incrementIncomingPackets(ctx.hostAddress(), opcode, length) + val messageClass = decoders.getMessageClass(this.decoder.javaClass) + val consumerRepository = networkService.gameMessageConsumerRepositoryProvider.provide() + val consumer = consumerRepository.consumers[messageClass] + if (consumer == null) { + networkLog(logger) { + "Discarding incoming game packet from channel '${ctx.channel()}': ${messageClass.simpleName}" + } + input.skipBytes(length) + state = DecoderState.READ_OPCODE + return + } + val payload = input.readSlice(length) + val message = decoder.decode(payload.toJagByteBuf()) + if (payload.isReadable) { + throw DecoderException( + "Decoder ${decoder.javaClass} did not read entire payload: ${payload.readableBytes()}, " + + "previous packets: ${buildPreviousPacketLog()}", + ) + } + out += message + session.incrementCounter(message as IncomingGameMessage) + if (session.isFull()) { + networkLog(logger) { + "Incoming packet limit reached, no longer reading " + + "incoming game packets from channel ${ctx.channel()}" + } + session.stopReading() + } + + state = DecoderState.READ_OPCODE + } + } + + @Suppress("unused") + private companion object { + /** + * The maximum size that a single packet can have in the client. + */ + private const val SINGLE_PACKET_MAX_PAYLOAD_LENGTH: Int = 5_000 + + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageEncoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageEncoder.kt new file mode 100644 index 000000000..5a7e9152c --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageEncoder.kt @@ -0,0 +1,45 @@ +package net.rsprot.protocol.api.game + +import io.netty.channel.ChannelHandlerContext +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.encoder.OutgoingMessageEncoder +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import net.rsprot.protocol.channel.binaryStreamOrNull +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository + +/** + * The game messages encoder, following the traditional outgoing message encoder. + */ +public class GameMessageEncoder( + public val networkService: NetworkService<*>, + override val cipher: StreamCipher, + client: OldSchoolClientType, +) : OutgoingMessageEncoder() { + override val repository: MessageEncoderRepository<*> = + networkService.encoderRepositories.gameMessageEncoderRepositories[client] + override val validate: Boolean = true + override val estimator: OutgoingMessageSizeEstimator = networkService.messageSizeEstimator + + override fun handlerAdded(ctx: ChannelHandlerContext) { + this.stream = ctx.channel().binaryStreamOrNull() + } + + override fun onMessageWritten( + ctx: ChannelHandlerContext, + opcode: Int, + payloadSize: Int, + ) { + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .incrementOutgoingPackets(ctx.hostAddress(), opcode, payloadSize) + } + + override fun mapOpcode(opcode: Int): Int { + val mapper = networkService.serverToClientOpcodeMapper ?: return opcode + return mapper.encode(opcode) + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageHandler.kt new file mode 100644 index 000000000..d92bc2bd1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/game/GameMessageHandler.kt @@ -0,0 +1,134 @@ +package net.rsprot.protocol.api.game + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.handler.timeout.IdleStateEvent +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.Session +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * The handler for game messages. + */ +public class GameMessageHandler( + private val networkService: NetworkService, + private val session: Session, +) : SimpleChannelInboundHandler(false) { + override fun handlerAdded(ctx: ChannelHandlerContext) { + // As auto-read is false, immediately begin reading once this handler + // has been added post-login + ctx.read() + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .incrementConnections(ctx.hostAddress()) + } + + override fun handlerRemoved(ctx: ChannelHandlerContext) { + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .decrementConnections(ctx.hostAddress()) + } + + override fun channelActive(ctx: ChannelHandlerContext) { + // Register this channel in the respective address tracker + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .register(ctx.hostAddress()) + networkLog(logger) { + "Channel is now active: ${ctx.channel()}" + } + } + + override fun channelInactive(ctx: ChannelHandlerContext) { + // Triggers the disconnection hook when the channel goes inactive. + // This is the earliest guaranteed known point of no return + try { + session.triggerIdleClosing() + } finally { + ctx.fireChannelInactive() + val address = ctx.hostAddress() + networkService.js5Authorizer.unauthorize(address) + // Must ensure both blocks of code get invoked, even if one throws an exception + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .deregister(address) + networkLog(logger) { + "Channel is now inactive: ${ctx.channel()}" + } + } + } + + override fun channelRead0( + ctx: ChannelHandlerContext, + msg: IncomingGameMessage, + ) { + networkLog(logger) { + "Incoming game message accepted from channel '${ctx.channel()}': $msg" + } + session.addIncomingMessage(msg) + } + + override fun channelWritabilityChanged(ctx: ChannelHandlerContext) { + if (ctx.channel().isWritable) { + networkLog(logger) { + "Channel '${ctx.channel()}' is now writable again, continuing to write game packets" + } + session.flush() + } + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) { + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, cause) + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + GameDisconnectionReason.EXCEPTION, + ) + val channel = ctx.channel() + if (channel.isOpen) { + channel.close() + } + } + + override fun userEventTriggered( + ctx: ChannelHandlerContext, + evt: Any, + ) { + // Handle idle states by disconnecting the user if this is hit. This is normally reached + // after 60 seconds of idle status. + if (evt is IdleStateEvent) { + networkLog(logger) { + "Login connection has gone idle, closing channel ${ctx.channel()}" + } + networkService + .trafficMonitor + .gameChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + GameDisconnectionReason.IDLE, + ) + ctx.close() + } + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/ExceptionHandlers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/ExceptionHandlers.kt new file mode 100644 index 000000000..8e03a5a6f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/ExceptionHandlers.kt @@ -0,0 +1,46 @@ +package net.rsprot.protocol.api.handlers + +import net.rsprot.protocol.api.ChannelExceptionHandler +import net.rsprot.protocol.api.IncomingGameMessageConsumerExceptionHandler +import net.rsprot.protocol.api.implementation.DefaultIncomingGameMessageConsumerExceptionHandler + +/** + * A wrapper class for all the exception handlers necessary to make this library function safely. + * @property channelExceptionHandler the exception handler for any exceptions caught by netty handlers + * @property incomingGameMessageConsumerExceptionHandler the exception handler for exceptions triggered + * via any message consumers, in order to allow the message processing to take place safely without + * the server needing to wrap each payload with its own exception handler + */ +public class ExceptionHandlers + @JvmOverloads + public constructor( + public val channelExceptionHandler: ChannelExceptionHandler, + public val incomingGameMessageConsumerExceptionHandler: IncomingGameMessageConsumerExceptionHandler = + DefaultIncomingGameMessageConsumerExceptionHandler(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ExceptionHandlers<*> + + if (channelExceptionHandler != other.channelExceptionHandler) return false + if (incomingGameMessageConsumerExceptionHandler != other.incomingGameMessageConsumerExceptionHandler) { + return false + } + + return true + } + + override fun hashCode(): Int { + var result = channelExceptionHandler.hashCode() + result = 31 * result + incomingGameMessageConsumerExceptionHandler.hashCode() + return result + } + + override fun toString(): String = + "ExceptionHandlers(" + + "channelExceptionHandler=$channelExceptionHandler, " + + "incomingGameMessageConsumerExceptionHandler=$incomingGameMessageConsumerExceptionHandler" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/GameMessageHandlers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/GameMessageHandlers.kt new file mode 100644 index 000000000..ce870f612 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/GameMessageHandlers.kt @@ -0,0 +1,52 @@ +package net.rsprot.protocol.api.handlers + +import net.rsprot.protocol.api.GameMessageCounterProvider +import net.rsprot.protocol.api.MessageQueueProvider +import net.rsprot.protocol.api.implementation.DefaultGameMessageCounterProvider +import net.rsprot.protocol.api.implementation.DefaultMessageQueueProvider +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * The handlers for incoming game messages. + * @property incomingGameMessageQueueProvider the queue provider for any incoming game messages + * @property outgoingGameMessageQueueProvider the queue provider for any outgoing game messages + * @property gameMessageCounterProvider the message counter provider, used to track the number + * of incoming messages over the span of one game cycle. + */ +public class GameMessageHandlers + @JvmOverloads + public constructor( + public val incomingGameMessageQueueProvider: MessageQueueProvider = + DefaultMessageQueueProvider(), + public val outgoingGameMessageQueueProvider: MessageQueueProvider = + DefaultMessageQueueProvider(), + public val gameMessageCounterProvider: GameMessageCounterProvider = DefaultGameMessageCounterProvider(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GameMessageHandlers + + if (incomingGameMessageQueueProvider != other.incomingGameMessageQueueProvider) return false + if (outgoingGameMessageQueueProvider != other.outgoingGameMessageQueueProvider) return false + if (gameMessageCounterProvider != other.gameMessageCounterProvider) return false + + return true + } + + override fun hashCode(): Int { + var result = incomingGameMessageQueueProvider.hashCode() + result = 31 * result + outgoingGameMessageQueueProvider.hashCode() + result = 31 * result + gameMessageCounterProvider.hashCode() + return result + } + + override fun toString(): String = + "GameMessageHandlers(" + + "incomingGameMessageQueueProvider=$incomingGameMessageQueueProvider, " + + "outgoingGameMessageQueueProvider=$outgoingGameMessageQueueProvider, " + + "gameMessageCounterProvider=$gameMessageCounterProvider" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/INetAddressHandlers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/INetAddressHandlers.kt new file mode 100644 index 000000000..3663aac3c --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/INetAddressHandlers.kt @@ -0,0 +1,48 @@ +package net.rsprot.protocol.api.handlers + +import net.rsprot.protocol.api.InetAddressTracker +import net.rsprot.protocol.api.InetAddressValidator +import net.rsprot.protocol.api.implementation.DefaultInetAddressTracker +import net.rsprot.protocol.api.implementation.DefaultInetAddressValidator + +/** + * The handlers for anything to do with INet addresses. + * @property hostAddressValidator the validator for new connections, responsible for rejecting + * any connections after a limit has been reached. + * @property js5InetAddressTracker the tracker for active JS5 connections + * @property gameInetAddressTracker the tracker for active game connections + */ +public class INetAddressHandlers + @JvmOverloads + public constructor( + public val inetAddressValidator: InetAddressValidator = DefaultInetAddressValidator(), + public val js5InetAddressTracker: InetAddressTracker = DefaultInetAddressTracker(), + public val gameInetAddressTracker: InetAddressTracker = DefaultInetAddressTracker(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as INetAddressHandlers + + if (inetAddressValidator != other.inetAddressValidator) return false + if (js5InetAddressTracker != other.js5InetAddressTracker) return false + if (gameInetAddressTracker != other.gameInetAddressTracker) return false + + return true + } + + override fun hashCode(): Int { + var result = inetAddressValidator.hashCode() + result = 31 * result + js5InetAddressTracker.hashCode() + result = 31 * result + gameInetAddressTracker.hashCode() + return result + } + + override fun toString(): String = + "INetAddressHandlers(" + + "inetAddressValidator=$inetAddressValidator, " + + "js5InetAddressTracker=$js5InetAddressTracker, " + + "gameInetAddressTracker=$gameInetAddressTracker" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/LoginHandlers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/LoginHandlers.kt new file mode 100644 index 000000000..2acb6b474 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/LoginHandlers.kt @@ -0,0 +1,89 @@ +package net.rsprot.protocol.api.handlers + +import net.rsprot.protocol.api.LoginDecoderService +import net.rsprot.protocol.api.SessionIdGenerator +import net.rsprot.protocol.api.StreamCipherProvider +import net.rsprot.protocol.api.implementation.DefaultLoginDecoderService +import net.rsprot.protocol.api.implementation.DefaultSessionIdGenerator +import net.rsprot.protocol.api.implementation.DefaultStreamCipherProvider +import net.rsprot.protocol.loginprot.incoming.pow.ProofOfWorkProvider +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeWorker +import net.rsprot.protocol.loginprot.incoming.pow.challenges.DefaultChallengeWorker +import net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.DefaultSha256ProofOfWorkProvider +import java.util.concurrent.ExecutorService +import java.util.concurrent.ForkJoinPool + +/** + * The handlers for anything to do with the login procedure. + * @property sessionIdGenerator the generator for session ids which are initially made + * at the very beginning when the client establishes a connection. This session id is + * furthermore passed whenever a login occurs and validated by the library to ensure it matches. + * @property streamCipherProvider the provider for game stream ciphers, by default, the stream + * cipher uses the normal OldSchool client implementation. + * @property loginDecoderService the decoder service responsible for decoding login blocks, + * as the RSA deciphering is fairly expensive, allowing this to be done on a different thread. + * @property proofOfWorkProvider the provider for proof of work which must be completed + * before a login can take place. If the provider returns null, no proof of work is used. + * @property proofOfWorkChallengeWorker the worker used to verify the validity of the challenge, + * allowing servers to execute this off of another thread. By default, this will be + * executed via the calling thread, as this is extremely fast to check. + * @property loginFlowExecutor the executor used to call [net.rsprot.protocol.api.GameConnectionHandler.onLogin] + * and [net.rsprot.protocol.api.GameConnectionHandler.onReconnect] functions. If the value is set to + * null, the function will be called directly from Netty's own threads. The default implementation + * is a ForkJoinPool to ensure that servers don't end up blocking Netty's threads. + * Servers which already ensure that can simply disable this. + * @property suppressInvalidLoginProts whether to suppress and kill the channel whenever an invalid + * login prot is received. This can be useful if the server is susceptible to web crawlers and + * anything of such nature which could lead into a lot of useless errors being thrown. + * By default, this is off, and errors will be thrown whenever an invalid prot is received. + */ +public class LoginHandlers + @JvmOverloads + public constructor( + public val sessionIdGenerator: SessionIdGenerator = DefaultSessionIdGenerator(), + public val streamCipherProvider: StreamCipherProvider = DefaultStreamCipherProvider(), + public val loginDecoderService: LoginDecoderService = DefaultLoginDecoderService(), + public val proofOfWorkProvider: ProofOfWorkProvider<*, *> = DefaultSha256ProofOfWorkProvider(1), + public val proofOfWorkChallengeWorker: ChallengeWorker = DefaultChallengeWorker, + public val loginFlowExecutor: ExecutorService? = ForkJoinPool.commonPool(), + public val suppressInvalidLoginProts: Boolean = false, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LoginHandlers + + if (sessionIdGenerator != other.sessionIdGenerator) return false + if (streamCipherProvider != other.streamCipherProvider) return false + if (loginDecoderService != other.loginDecoderService) return false + if (proofOfWorkProvider != other.proofOfWorkProvider) return false + if (proofOfWorkChallengeWorker != other.proofOfWorkChallengeWorker) return false + if (loginFlowExecutor != other.loginFlowExecutor) return false + if (suppressInvalidLoginProts != other.suppressInvalidLoginProts) return false + + return true + } + + override fun hashCode(): Int { + var result = sessionIdGenerator.hashCode() + result = 31 * result + streamCipherProvider.hashCode() + result = 31 * result + loginDecoderService.hashCode() + result = 31 * result + proofOfWorkProvider.hashCode() + result = 31 * result + proofOfWorkChallengeWorker.hashCode() + result = 31 * result + loginFlowExecutor.hashCode() + result = 31 * result + suppressInvalidLoginProts.hashCode() + return result + } + + override fun toString(): String = + "LoginHandlers(" + + "sessionIdGenerator=$sessionIdGenerator, " + + "streamCipherProvider=$streamCipherProvider, " + + "loginDecoderService=$loginDecoderService, " + + "proofOfWorkProvider=$proofOfWorkProvider, " + + "proofOfWorkChallengeWorker=$proofOfWorkChallengeWorker, " + + "loginFlowExecutor=$loginFlowExecutor, " + + "suppressInvalidLoginProts=$suppressInvalidLoginProts" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/OutgoingMessageSizeEstimator.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/OutgoingMessageSizeEstimator.kt new file mode 100644 index 000000000..30559afaf --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/OutgoingMessageSizeEstimator.kt @@ -0,0 +1,142 @@ +package net.rsprot.protocol.api.handlers + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufHolder +import io.netty.channel.FileRegion +import io.netty.channel.MessageSizeEstimator +import net.rsprot.protocol.Prot +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.api.repositories.MessageEncoderRepositories +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.OutgoingJs5Message +import net.rsprot.protocol.message.OutgoingLoginMessage +import net.rsprot.protocol.message.OutgoingMessage + +public class OutgoingMessageSizeEstimator( + repositories: MessageEncoderRepositories, +) : MessageSizeEstimator { + private val supportsMultiplePlatforms = + repositories + .gameMessageEncoderRepositories + .notNullSize > 1 + private val gameEncoder = + repositories + .gameMessageEncoderRepositories[ESTIMATOR_CLIENT_TYPE] + private val loginEncoder = + repositories + .loginMessageEncoderRepository + + private val singleton = OutgoingMessageSizeEstimatorHandle() + + override fun newHandle(): MessageSizeEstimator.Handle = singleton + + private inner class OutgoingMessageSizeEstimatorHandle : MessageSizeEstimator.Handle { + override fun size(msg: Any): Int { + try { + when (msg) { + is OutgoingGameMessage -> { + return estimateGameMessageSize(msg) + } + is OutgoingLoginMessage -> { + return estimateLoginMessageSize(msg) + } + is OutgoingJs5Message -> { + return estimateJs5MessageSize(msg) + } + } + if (msg is ByteBuf) { + return msg.readableBytes() + } + if (msg is ByteBufHolder) { + return msg.content().readableBytes() + } + if (msg is FileRegion) { + return FILE_REGION_SIZE + } + return UNKNOWN_MESSAGE_SIZE + } catch (t: Throwable) { + logger.error(t) { + "Unable to estimate the size of message $msg" + } + return UNKNOWN_MESSAGE_SIZE + } + } + + private fun estimateGameMessageSize(msg: OutgoingGameMessage): Int { + val prot = gameEncoder.getEncoder(msg.javaClass).prot + return estimateRegularProtocolMessage(msg, prot) + } + + private fun estimateLoginMessageSize(msg: OutgoingLoginMessage): Int { + val prot = loginEncoder.getEncoder(msg.javaClass).prot + return estimateRegularProtocolMessage(msg, prot) + } + + private fun estimateJs5MessageSize(msg: OutgoingJs5Message): Int { + val estimate = msg.estimateSize() + if (estimate != -1) { + return estimate + } + if (msg is ByteBufHolder) { + return msg.content().readableBytes() + } + return UNKNOWN_JS5_MESSAGE_PAYLOAD_SIZE + } + + private fun estimateRegularProtocolMessage( + msg: OutgoingMessage, + prot: ServerProt, + ): Int { + // First reserve one or two bytes for the opcode, depending on circumstances + // If we know there's only the desktop platform registered, we can rely on that + // to know if the opcode requires two bytes or one + // If multiple platforms are used however, we always just assume two bytes + // to avoid the expensive resizing operation + var headerSize = + if (supportsMultiplePlatforms || prot.opcode >= TWO_BYTE_OPCODE_THRESHOLD) { + Short.SIZE_BYTES + } else { + Byte.SIZE_BYTES + } + + // Next up, add 1 byte for var-byte and 2 bytes for var-short + // If the message is not a var-* one, add the constant size and return early + val constantSize = prot.size + headerSize += + if (constantSize == Prot.VAR_BYTE) { + Byte.SIZE_BYTES + } else if (constantSize == Prot.VAR_SHORT) { + Short.SIZE_BYTES + } else { + // If no dynamic payload, return here + return headerSize + constantSize + } + + // If we override the estimation, add that to the size and return it + val estimate = msg.estimateSize() + if (estimate != -1) { + return headerSize + estimate + } + // If an override was not provided, check if the message is a byte buf holder which + // will already tell is the exact size of the message + if (msg is ByteBufHolder) { + return headerSize + msg.content().readableBytes() + } + + // If all else fails, just assume the payload size is 8 bytes. + // We should try to avoid reaching this stage as it could be off quite a lot + return headerSize + UNKNOWN_MESSAGE_SIZE + } + } + + private companion object { + private val ESTIMATOR_CLIENT_TYPE: OldSchoolClientType = OldSchoolClientType.DESKTOP + private const val TWO_BYTE_OPCODE_THRESHOLD: Int = 0x80 + private const val FILE_REGION_SIZE: Int = 0 + private const val UNKNOWN_MESSAGE_SIZE: Int = 8 + private const val UNKNOWN_JS5_MESSAGE_PAYLOAD_SIZE: Int = 512 + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/DefaultIdleStateHandlerSupplier.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/DefaultIdleStateHandlerSupplier.kt new file mode 100644 index 000000000..df5c35cd2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/DefaultIdleStateHandlerSupplier.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.api.handlers.idlestate + +import io.netty.handler.timeout.IdleStateHandler +import java.util.concurrent.TimeUnit + +public open class DefaultIdleStateHandlerSupplier + @JvmOverloads + constructor( + public val idleTime: Long, + public val idleTimeUnit: TimeUnit = TimeUnit.SECONDS, + public val observeOutput: Boolean = true, + ) : IdleStateHandlerSupplier { + override fun supply(): IdleStateHandler = + IdleStateHandler( + observeOutput, + idleTime, + idleTime, + 0, + idleTimeUnit, + ) + + public object Initial : DefaultIdleStateHandlerSupplier(30) + + public object Login : DefaultIdleStateHandlerSupplier(40) + + public object Game : DefaultIdleStateHandlerSupplier(15) + + public object JS5 : DefaultIdleStateHandlerSupplier(30) + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSupplier.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSupplier.kt new file mode 100644 index 000000000..f7e10dbfa --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSupplier.kt @@ -0,0 +1,7 @@ +package net.rsprot.protocol.api.handlers.idlestate + +import io.netty.handler.timeout.IdleStateHandler + +public fun interface IdleStateHandlerSupplier { + public fun supply(): IdleStateHandler +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSuppliers.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSuppliers.kt new file mode 100644 index 000000000..389f74d88 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/handlers/idlestate/IdleStateHandlerSuppliers.kt @@ -0,0 +1,19 @@ +package net.rsprot.protocol.api.handlers.idlestate + +import io.netty.handler.timeout.IdleStateHandler + +/** + * The suppliers which supply [IdleStateHandler]s. + */ +public class IdleStateHandlerSuppliers + @JvmOverloads + constructor( + public val initialSupplier: IdleStateHandlerSupplier = + DefaultIdleStateHandlerSupplier.Initial, + public val loginSupplier: IdleStateHandlerSupplier = + DefaultIdleStateHandlerSupplier.Login, + public val gameSupplier: IdleStateHandlerSupplier = + DefaultIdleStateHandlerSupplier.Game, + public val js5Supplier: IdleStateHandlerSupplier = + DefaultIdleStateHandlerSupplier.JS5, + ) diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounter.kt new file mode 100644 index 000000000..0f533db33 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounter.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.api.GameMessageCounter +import net.rsprot.protocol.game.incoming.GameClientProtCategory + +/** + * A default game message counter that follows the normal OldSchool limitations, + * allowing for up to 10 user events and up to 50 client events, stopping decoding + * whenever either of the limitations is reached. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class DefaultGameMessageCounter + @JvmOverloads + constructor( + public val clientEventLimit: Int = 50, + public val userEventLimit: Int = 10, + ) : GameMessageCounter { + private val counts: IntArray = IntArray(PROT_TYPES_COUNT) + + override fun increment(clientProtCategory: ClientProtCategory) { + counts[clientProtCategory.id]++ + } + + override fun isFull(): Boolean = + counts[GameClientProtCategory.CLIENT_EVENT.id] >= clientEventLimit || + counts[GameClientProtCategory.USER_EVENT.id] >= userEventLimit + + override fun reset() { + counts.fill(0) + } + + private companion object { + private const val PROT_TYPES_COUNT: Int = 2 + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounterProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounterProvider.kt new file mode 100644 index 000000000..43e03bcb9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultGameMessageCounterProvider.kt @@ -0,0 +1,11 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.api.GameMessageCounter +import net.rsprot.protocol.api.GameMessageCounterProvider + +/** + * The provider for the default game messages + */ +public class DefaultGameMessageCounterProvider : GameMessageCounterProvider { + override fun provide(): GameMessageCounter = DefaultGameMessageCounter() +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultIncomingGameMessageConsumerExceptionHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultIncomingGameMessageConsumerExceptionHandler.kt new file mode 100644 index 000000000..db2b7bba8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultIncomingGameMessageConsumerExceptionHandler.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.api.implementation + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.api.IncomingGameMessageConsumerExceptionHandler +import net.rsprot.protocol.api.Session +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * The default handler for incoming game messages, which will simply log the exceptions + * and errors, and in the case of errors, propagate them further. For any exceptions, + * nothing besides logging is done. + */ +public class DefaultIncomingGameMessageConsumerExceptionHandler : IncomingGameMessageConsumerExceptionHandler { + override fun exceptionCaught( + session: Session, + packet: IncomingGameMessage, + cause: Throwable, + ) { + logger.error(cause) { + "Exception during consumption of $packet for channel ${session.ctx.channel()}" + } + // Propagate errors forward + if (cause is Error) { + throw cause + } + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressTracker.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressTracker.kt new file mode 100644 index 000000000..0a0031df8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressTracker.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.api.InetAddressTracker +import java.util.concurrent.ConcurrentHashMap + +/** + * The default tracker for INet addresses, utilizing a concurrent hash map. + */ +public class DefaultInetAddressTracker : InetAddressTracker { + private val counts: MutableMap = ConcurrentHashMap() + + override fun register(address: String) { + counts.compute(address) { _, value -> + (value ?: 0) + 1 + } + } + + override fun deregister(address: String) { + counts.compute(address) { _, value -> + if (value == null || value <= 1) { + null + } else { + value - 1 + } + } + } + + override fun getCount(address: String): Int = counts.getOrDefault(address, 0) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressValidator.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressValidator.kt new file mode 100644 index 000000000..73743941f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultInetAddressValidator.kt @@ -0,0 +1,39 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.api.InetAddressValidator + +/** + * The default validation for a max number of concurrent active connections + * from a specific INet address, limited to 10 by default. + */ +public class DefaultInetAddressValidator( + public val limit: Int = MAX_CONNECTIONS, +) : InetAddressValidator { + override fun acceptGameConnection( + address: String, + activeGameConnections: Int, + ): Boolean = activeGameConnections < limit + + override fun acceptJs5Connection( + address: String, + activeJs5Connections: Int, + seed: IntArray, + ): Boolean = activeJs5Connections < limit + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DefaultInetAddressValidator + + return limit == other.limit + } + + override fun hashCode(): Int = limit + + override fun toString(): String = "DefaultInetAddressValidator(limit=$limit)" + + private companion object { + private const val MAX_CONNECTIONS: Int = 10 + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultLoginDecoderService.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultLoginDecoderService.kt new file mode 100644 index 000000000..054ad1cd8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultLoginDecoderService.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.api.LoginDecoderService +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.LoginBlockDecodingFunction +import java.util.concurrent.CompletableFuture + +/** + * The default login decoder utilizing a ForkJoinPool to decode the login block. + */ +public class DefaultLoginDecoderService : LoginDecoderService { + override fun decode( + buffer: JagByteBuf, + betaWorld: Boolean, + header: LoginBlock.Header, + decoder: LoginBlockDecodingFunction, + ): CompletableFuture> = + CompletableFuture>().completeAsync { + decoder.decode(header, buffer, betaWorld) + } + + override fun decodeHeader( + buffer: JagByteBuf, + decoder: LoginBlockDecodingFunction<*>, + ): LoginBlock.Header { + return decoder.decodeHeader(buffer) + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultMessageQueueProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultMessageQueueProvider.kt new file mode 100644 index 000000000..7b3f28573 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultMessageQueueProvider.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.api.MessageQueueProvider +import net.rsprot.protocol.message.Message +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * The default message queue provider, returning a concurrent linked queue. + */ +public class DefaultMessageQueueProvider : MessageQueueProvider { + override fun provide(): Queue = ConcurrentLinkedQueue() +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultSessionIdGenerator.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultSessionIdGenerator.kt new file mode 100644 index 000000000..f927ea495 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultSessionIdGenerator.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.protocol.api.SessionIdGenerator +import java.security.SecureRandom + +/** + * The default session id generator, using a secure random to generate the ids. + */ +public class DefaultSessionIdGenerator : SessionIdGenerator { + private val random = SecureRandom() + + override fun generate(address: String): Long = random.nextLong() +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultStreamCipherProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultStreamCipherProvider.kt new file mode 100644 index 000000000..04bf15f36 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/implementation/DefaultStreamCipherProvider.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.api.implementation + +import net.rsprot.crypto.cipher.IsaacRandom +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.api.StreamCipherProvider + +/** + * The default stream cipher provider, returning an instance of the ISAAC random + * stream cipher based on the input seed. + */ +public class DefaultStreamCipherProvider : StreamCipherProvider { + override fun provide(seed: IntArray): StreamCipher = IsaacRandom(seed) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/ConcurrentJs5Authorizer.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/ConcurrentJs5Authorizer.kt new file mode 100644 index 000000000..0ce0e7353 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/ConcurrentJs5Authorizer.kt @@ -0,0 +1,109 @@ +package net.rsprot.protocol.api.js5 + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.loginprot.incoming.RemainingBetaArchives +import java.util.concurrent.ConcurrentHashMap + +/** + * A JS5 authorizer that utilizes a concurrent hashmap to keep track of how many times + * an [String] has been authorized, to allow multiple clients to keep downloading + * the cache, if necessary. + * Furthermore, utilizes a [Long] bitmask of [protectedArchives] for performant authorization + * validation. + * + * @param protectedArchives the list of protected archive ids which require authorization + * to download the groups for. + */ +public class ConcurrentJs5Authorizer( + protectedArchives: List, +) : Js5Authorizer { + public constructor() : this(RemainingBetaArchives.protectedArchives) + + private val counts = ConcurrentHashMap(DEFAULT_CAPACITY) + private val protectedArchivesBitMask: Long = buildProtectedArchivesBitMask(protectedArchives) + + private fun buildProtectedArchivesBitMask(protectedArchives: List): Long { + return protectedArchives.fold(0L) { acc, value -> + acc or (1L shl value) + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun isProtected(archive: Int): Boolean { + return protectedArchivesBitMask and (1L shl archive) != 0L + } + + override fun authorize(address: String) { + try { + counts.compute(address) { _, old -> + if (old != null) { + old + 1 + } else { + // Validation in case there is some logic flaw that results in addresses not getting + // cleaned up when they should - in which case just begin rejecting any future ones. + // Note that this isn't atomic, it's just a rough limit, we don't care for precise amounts. + if (counts.size >= MAXIMUM_CAPACITY) { + logger.error { + "Authorized JS5 addresses has reached $MAXIMUM_CAPACITY entries - possible memory leak?" + } + null + } else { + 1 + } + } + } + } catch (e: Exception) { + logger.error(e) { + "Unable to authorize $address" + } + } + } + + override fun unauthorize(address: String) { + try { + counts.compute(address) { _, old -> + when { + old == null || old <= 1 -> null + else -> old - 1 + } + } + } catch (e: Exception) { + logger.error(e) { + "Unable to unauthorize $address" + } + } + } + + override fun isAuthorized( + address: String, + archive: Int, + ): Boolean { + return try { + !isProtected(archive) || isAuthorized(address) + } catch (e: Exception) { + logger.error(e) { + "Unable to check for authorization: $archive @ $address" + } + false + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun isAuthorized(address: String): Boolean { + val count = counts[address] + return count != null && count > 0 + } + + override fun toString(): String { + return "ConcurrentJs5Authorizer(" + + "protectedArchivesBitMask=$protectedArchivesBitMask, " + + "counts=$counts" + + ")" + } + + private companion object { + private const val MAXIMUM_CAPACITY: Int = 1_000_000 + private const val DEFAULT_CAPACITY: Int = 2048 + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Authorizer.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Authorizer.kt new file mode 100644 index 000000000..9e5ed0744 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Authorizer.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.api.js5 + +/** + * An interface to authorize certain [String]es, and check if they are authorized + * to download certain parts of the cache. This feature is only enabled if the world + * has the beta flag enabled. + */ +public interface Js5Authorizer { + /** + * Authorizes an INetAddress to download the cache in its entirety on beta worlds. + * + * @param address the [String] to authorize. + */ + public fun authorize(address: String) + + /** + * Authorizes an INetAddress, so it can no longer download certain archives of the cache. + * Note that it is possible for multiple connections to be open by the user. + * In such cases, the authorization remains active until the last connection dies. + * + * @param address the [String] to authorize. + */ + public fun unauthorize(address: String) + + /** + * Checks if a cache archive is authorized for the specified [String]. + * + * @param address the [String] to check for authorization. + * @param archive the cache archive to check. Only certain cache archives + * are protected. + */ + public fun isAuthorized( + address: String, + archive: Int, + ): Boolean +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5ChannelHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5ChannelHandler.kt new file mode 100644 index 000000000..8e1d10777 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5ChannelHandler.kt @@ -0,0 +1,168 @@ +package net.rsprot.protocol.api.js5 + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.handler.timeout.IdleStateEvent +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.logging.js5Log +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.js5.incoming.Js5GroupRequest +import net.rsprot.protocol.js5.incoming.PriorityChangeHigh +import net.rsprot.protocol.js5.incoming.PriorityChangeLow +import net.rsprot.protocol.js5.incoming.XorChange +import net.rsprot.protocol.message.IncomingJs5Message + +/** + * A channel handler for the JS5 connections + */ +public class Js5ChannelHandler( + private val networkService: NetworkService<*>, +) : SimpleChannelInboundHandler(IncomingJs5Message::class.java) { + private lateinit var client: Js5Client + private val service: Js5Service + get() = networkService.js5Service + + override fun channelActive(ctx: ChannelHandlerContext) { + networkService + .iNetAddressHandlers + .js5InetAddressTracker + .register(ctx.hostAddress()) + networkLog(logger) { + "Js5 channel '${ctx.channel()}' is now active" + } + } + + override fun channelInactive(ctx: ChannelHandlerContext) { + networkService + .iNetAddressHandlers + .js5InetAddressTracker + .deregister(ctx.hostAddress()) + networkLog(logger) { + "Js5 channel '${ctx.channel()}' is now inactive" + } + } + + override fun handlerAdded(ctx: ChannelHandlerContext) { + // Instantiate the client when the handler is added, additionally read from the ctx + client = Js5Client(ctx.read()) + service.onClientConnected(client) + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .incrementConnections(ctx.hostAddress()) + } + + override fun handlerRemoved(ctx: ChannelHandlerContext) { + service.onClientDisconnected(client) + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .decrementConnections(ctx.hostAddress()) + } + + override fun channelRead0( + ctx: ChannelHandlerContext, + msg: IncomingJs5Message, + ) { + // Directly handle all the possible message types in descending order of + // probability of being sent + when (msg) { + is Js5GroupRequest -> { + js5Log(logger) { + "JS5 group request from channel '${ctx.channel()}' received: $msg" + } + service.push(client, msg) + } + PriorityChangeLow -> { + js5Log(logger) { + "Priority changed to low in channel ${ctx.channel()}" + } + client.setLowPriority() + service.readIfNotFull(client) + // Furthermore, notify the client as we might've transferred prefetch over + service.notifyIfNotEmpty(client) + } + PriorityChangeHigh -> { + js5Log(logger) { + "Priority changed to high in channel ${ctx.channel()}" + } + client.setHighPriority() + service.readIfNotFull(client) + } + is XorChange -> { + js5Log(logger) { + "Encryption key received from channel '${ctx.channel()}': $msg" + } + service.use { + client.setXorKey(msg.key) + service.readIfNotFull(client) + } + } + else -> { + throw IllegalStateException("Unknown JS5 message: $msg") + } + } + } + + override fun channelReadComplete(ctx: ChannelHandlerContext) { + // Read more from the context if we have space to read, when the read has completed + service.readIfNotFull(client) + } + + override fun channelWritabilityChanged(ctx: ChannelHandlerContext) { + // If the channel turns writable again, allow the service to continue + // serving to this client + if (ctx.channel().isWritable) { + service.notifyIfNotEmpty(client) + } + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) { + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, cause) + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + Js5DisconnectionReason.EXCEPTION, + ) + val channel = ctx.channel() + if (channel.isOpen) { + channel.close() + } + } + + override fun userEventTriggered( + ctx: ChannelHandlerContext, + evt: Any, + ) { + // Close the context if the channel goes idle + if (evt is IdleStateEvent) { + networkLog(logger) { + "JS5 channel has gone idle, closing channel ${ctx.channel()}" + } + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + Js5DisconnectionReason.IDLE, + ) + ctx.close() + } + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Client.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Client.kt new file mode 100644 index 000000000..c2853602f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Client.kt @@ -0,0 +1,425 @@ +package net.rsprot.protocol.api.js5 + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.js5.Js5Client.ClientPriority.HIGH +import net.rsprot.protocol.api.js5.Js5Client.ClientPriority.LOW +import net.rsprot.protocol.api.js5.util.IntArrayDeque +import net.rsprot.protocol.api.logging.js5Log +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.common.js5.outgoing.prot.Js5ServerProt +import net.rsprot.protocol.js5.incoming.Js5GroupRequest +import net.rsprot.protocol.js5.incoming.UrgentRequest +import net.rsprot.protocol.js5.outgoing.Js5GroupResponse +import kotlin.math.min + +/** + * The JS5 client is responsible for keeping track of all the requests and state of + * a connected client. + * @property ctx the channel handler context behind this client + * @property urgent the array deque for any urgent requests - if any exist, these will be served + * before the prefetch requests + * @property prefetch the array deque for any prefetch requests + * @property currentRequest the current partial request, since the service allows fair serving, + * we may only write a sector of a group at a time, rather than the full thing + * @property priority the current priority of this client. If the client is logged in, + * they will have a higher priority and the service will by default write 3x as much data + * to that channel compared to any other client that is set to a low, logged-out priority. + * @property writtenByteCount the number of bytes written since the last flush + * @property writtenGroupCount the number of group writes that have completed since + * the last flush + */ +public class Js5Client( + public val ctx: ChannelHandlerContext, +) { + private val address = ctx.hostAddress() + private val urgent: IntArrayDeque = IntArrayDeque(INITIAL_QUEUE_SIZE) + private val prefetch: IntArrayDeque = IntArrayDeque(INITIAL_QUEUE_SIZE) + private val awaitingPrefetch: IntArrayDeque = IntArrayDeque(INITIAL_QUEUE_SIZE) + private val currentRequest: PartialJs5GroupRequest = PartialJs5GroupRequest() + private var lowPriorityChangeCount: Int = 0 + public var priority: ClientPriority = ClientPriority.LOW + private set + + private var writtenByteCount: Int = 0 + private var writtenGroupCount: Int = 0 + private var xorKey: Int = 0 + + /** + * Gets the next block response for this channel, typically a section of a cache group. + * @param networkService the main network service, providing access to all the network needs. + * @param authorizer the authorizer to validate a js5 group request. + * @param behaviour the behaviour for missing JS5 groups, dictating what should be done when + * the client makes a request that simply does not exist. + * @param provider the provider for JS5 groups + * @param blockLength the maximum size of a block to write in a single call. + * If the number of bytes left in this group to write is less than the block, + * then less bytes are written than expected. + * @return a group response to write to the client, or null if none exists + */ + public fun getNextBlock( + networkService: NetworkService<*>, + authorizer: Js5Authorizer, + behaviour: Js5Configuration.Js5MissingGroupBehaviour, + provider: Js5GroupProvider, + blockLength: Int, + ): Js5GroupResponse? { + var block: ByteBuf? = currentRequest.block + if (block == null || currentRequest.isComplete()) { + val request = pop() + if (request == -1) { + return null + } + val archiveId = request ushr 16 + val groupId = request and 0xFFFF + // If unauthorized, log it and go for the next request. The client will + // never receive a response about it. + if (!authorizer.isAuthorized(address, archiveId)) { + js5Log(logger) { + "Unauthorized JS5 group request $archiveId:$groupId by $address" + } + return getNextBlock( + networkService, + authorizer, + behaviour, + provider, + blockLength, + ) + } + js5Log(logger) { + "Assigned next request block: $archiveId:$groupId" + } + try { + block = provider.provide(archiveId, groupId) + } catch (t: Throwable) { + throw RuntimeException("Failed to respond to request $archiveId:$groupId", t) + } + if (block == null) { + when (behaviour) { + Js5Configuration.Js5MissingGroupBehaviour.DROP_REQUEST -> { + if (remainingInvalidGroupLogs > 0) { + --remainingInvalidGroupLogs + logger.warn { "Invalid JS5 group request received: $archiveId:$groupId" } + } + // If there's no response for the previous group, we drop the request and + // try to handle the next one in the pipeline. + return getNextBlock( + networkService, + authorizer, + behaviour, + provider, + blockLength, + ) + } + Js5Configuration.Js5MissingGroupBehaviour.DROP_CONNECTION -> { + if (remainingInvalidGroupLogs > 0) { + --remainingInvalidGroupLogs + logger.warn { "Invalid JS5 group request received: $archiveId:$groupId" } + } + // Propagate it forward as an exception, upstream will close the channel. + throw NullPointerException("Group $archiveId:$groupId does not exist.") + } + } + } + if (!bufferSlicingValidated && block.readableBytes() > Js5Service.BLOCK_LENGTH) { + bufferSlicingValidated = true + val isValid = Js5Service.ensureCorrectlySliced(block) + if (!isValid) { + logger.warn { + "JS5 buffer for $archiveId:$groupId has not been correctly sliced up! " + + "Byte buffers given to RSProt must be prepared via Js5Service.prepareJs5Buffer()." + } + } + } + currentRequest.set(block) + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .incrementOutgoingPacketOpcode(ctx.hostAddress(), Js5ServerProt.JS5_GROUP_RESPONSE.opcode) + } + val progress = currentRequest.progress + val length = currentRequest.getNextBlockLengthAndIncrementProgress(blockLength) + writtenByteCount += length + if (currentRequest.isComplete()) { + writtenGroupCount++ + } + return Js5GroupResponse( + block.slice(progress, length), + xorKey, + ) + } + + /** + * Pushes a JS5 request to this client, adding it to the end of the respective queue. + * If this is an urgent request, the request itself is removed from the prefetch list, + * if it exists. This is a one-way operation, however, as the client by default + * can only request duplicate requests via this manner. Any modifications to the client + * can by-pass this, but that is a non issue since we offer a fair JS5 service where + * the actual request doesn't matter and the number of bytes written is all the same + * to everyone connected. + * @param request the request to add to this client + */ + public fun push(request: Js5GroupRequest) { + val bitpacked = request.bitpacked + if (request is UrgentRequest) { + prefetch.remove(bitpacked) + awaitingPrefetch.remove(bitpacked) + urgent.addLast(bitpacked) + } else { + // If on login screen (NOT pre-login screen), do not throttle prefetch + if (lowPriorityChangeCount >= 2 && priority == ClientPriority.LOW) { + this.prefetch.addLast(bitpacked) + } else { + awaitingPrefetch.addLast(bitpacked) + } + } + } + + /** + * Pops a request from this client, prioritizing urgent requests before prefetch. + * @return the bitpacked id of the request, or -1 if the queues are empty. + */ + private fun pop(): Int { + val urgent = urgent.removeFirstOrDefault(-1) + if (urgent != -1) { + return urgent + } + return prefetch.removeFirstOrDefault(-1) + } + + /** + * Transfers [threshold] worth of bytes of prefetch requests to be served + * to the client. + * @param groupProvider the provider for JS5 group sizes, allowing for proper throttling + * @param threshold the threshold at which the loop breaks, stopping any more bytes + * being transmitted via prefetch than described here + * @return whether any bytes were transferred to be served to the client. + */ + internal fun transferPrefetch( + groupProvider: Js5GroupProvider, + threshold: Int, + ): Boolean { + // Only transfer prefetch over if the connection is effectively idle + if (awaitingPrefetch.isEmpty() || + urgent.isNotEmpty() || + prefetch.isNotEmpty() || + !currentRequest.isComplete() || + !ctx.channel().isActive || + !ctx.channel().isWritable + ) { + return false + } + var transferredBytes = 0 + while (true) { + val next = awaitingPrefetch.removeFirstOrDefault(-1) + if (next == -1) { + break + } + val archiveId = next ushr 16 + val groupId = next and 0xFFFF + val size = groupProvider.provide(archiveId, groupId)?.readableBytes() ?: 0 + prefetch.addLast(next) + transferredBytes += size + // Do not clog the pipeline with more than threshold of prefetch data at a time, as this results + // in urgent requests being delayed + if (size >= threshold) { + break + } + } + return transferredBytes > 0 + } + + /** + * Transfers all prefetch requests from the throttled collection over to the + * non-throttled one. This is one whenever the client goes on login-screen. + */ + private fun transferAllPrefetch() { + while (true) { + val next = awaitingPrefetch.removeFirstOrDefault(-1) + if (next == -1) { + break + } + prefetch.addLast(next) + } + } + + /** + * Sets this client in a low priority mode, meaning it gets served less data + * than those that have a higher priority. + * This happens when a player logs out of the game. + */ + public fun setLowPriority() { + this.priority = ClientPriority.LOW + // A bit of a state machine, as client sets priority to low when it first connects, + // and once more when it reaches the login screen. + // Since our goal is to give urgent requests max priority before login screen to speed + // up load times, we use a boolean flag to indicate when to stop throttling prefetch requests. + // Once the second (or any furthermore) low priority response is received, the throttle is removed. + if (++lowPriorityChangeCount >= 2) { + transferAllPrefetch() + } + } + + /** + * Sets this client in a high priority state, meaning it gets served more data than + * those that have a lower priority. + * The client switches to high priority when the player logs into the game. + */ + public fun setHighPriority() { + this.priority = ClientPriority.HIGH + } + + /** + * Sets the pending encryption key for this client. + * Client sends this when it receives corrupt data for a group, in which case + * it will close the old socket first, allowing for a new one to be opened. + * In that new one, the encryption key is first sent out, followed by any requests + * it was previously waiting on, as those get transferred from the "awaiting response" + * over to the "awaiting to be requested" map. + * + * A potential theory for why this exist is network filters for HTTP traffic. + * The client can listen to port 443 which is commonly used for HTTP traffic, + * and some groups in the cache are not compressed at all. If said groups + * contain normal text that would fail any network filters, such as those + * set by schools, this could be a way to bypass these filters by re-requesting + * the data with it re-encrypted, meaning it won't get caught in the filters again. + * Besides compression, it is possible by pure chance that a sequence of bytes + * doesn't pass the filters, too. + * @param key the encryption key to use + */ + public fun setXorKey(key: Int) { + xorKey = key + } + + /** + * Checks that the JS5 client isn't full and can accept more requests in both queues. + */ + public fun isNotFull(): Boolean = + urgent.size < MAX_QUEUE_SIZE && (prefetch.size + awaitingPrefetch.size) < (MAX_QUEUE_SIZE + 1) + + /** + * Checks if the client is empty of any requests and has no pending request to still write. + */ + private fun isEmpty(): Boolean = currentRequest.isComplete() && urgent.isEmpty() && prefetch.isEmpty() + + /** + * Checks that the client is not empty, meaning it has some requests, or a group is half-written. + */ + public fun isNotEmpty(): Boolean = !isEmpty() + + /** + * Checks if the client is ready by ensuring it can be written to, and there is some data + * to be written to the client. + */ + public fun isReady(): Boolean = ctx.channel().isWritable && isNotEmpty() + + /** + * Checks if the client needs flushing based on the input thresholds. + * @param flushThresholdInBytes the number of bytes that must be written since the last flush, + * before a flush will occur. Note that the flush only occurs in this case if at least one + * full group has been finished, as there's no reason to flush an incomplete group, + * the client will not be able to continue anyhow. + * @param flushThresholdInGroups the number of full groups written to the client before + * a flush should occur. + */ + public fun needsFlushing( + flushThresholdInBytes: Int, + flushThresholdInGroups: Int, + ): Boolean = + writtenGroupCount >= flushThresholdInGroups || + (writtenGroupCount > 0 && writtenByteCount >= flushThresholdInBytes) || + (writtenByteCount > 0 && isEmpty()) || + !ctx.channel().isWritable + + /** + * Resets the number of bytes and groups written. + */ + public fun resetTracker() { + writtenByteCount = 0 + writtenGroupCount = 0 + } + + /** + * A class for tracking partial JS5 group requests, allowing us to feed the groups + * sections at a time, instead of the full thing - this is due to the groups + * varying greatly in size and naively sending a group could open it up for attacks. + * @property block the current block that is being written + * @property progress the current number of bytes written of this block + * @property length the total number of bytes of this block until it has been + * completely written over. + */ + public class PartialJs5GroupRequest { + public var block: ByteBuf? = null + private set + public var progress: Int = 0 + private set + private var length: Int = 0 + + /** + * Checks whether this group has been fully written over to the client + */ + public fun isComplete(): Boolean = progress >= length + + /** + * Gets the length of the next block, capped to [blockLength], + * and increments the current progress by that value. + */ + public fun getNextBlockLengthAndIncrementProgress(blockLength: Int): Int { + val progress = this.progress + this.progress = min(this.length, this.progress + blockLength) + return this.progress - progress + } + + /** + * Sets a new block to be written to the client, resetting the progress and + * updating the length to that of this block. + */ + public fun set(block: ByteBuf) { + this.block = block + this.progress = 0 + this.length = block.readableBytes() + } + } + + /** + * The possible client priority values. + * @property LOW is used when the client is logged out, e.g. in the loading + * screen or at the login screen + * @property HIGH is used when the client is logged into the game. + */ + public enum class ClientPriority { + LOW, + HIGH, + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + + /** + * Initialize all the queues as size-0 by default. + * This ensures that any connection-based attack cannot result in an out-of-memory error. + */ + private const val INITIAL_QUEUE_SIZE: Int = 0 + + /** + * The maximum number of requests the client can send out per each group at a time. + */ + private const val MAX_QUEUE_SIZE: Int = 200 + + /** + * The number of invalid group logs that will still be warned about. + * We only log up to 100 of the first invalid JS5 group requests that get made, + * in order to avoid a scenario where the logging becomes an attack vector of its own. + */ + private var remainingInvalidGroupLogs: Int = 100 + + /** + * A boolean to run a one-time check to ensure that the buffer the server gives has + * been correctly sliced up with terminators. This offers an easier way of debugging + * problems with the JS5 system. + */ + private var bufferSlicingValidated: Boolean = false + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Configuration.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Configuration.kt new file mode 100644 index 000000000..e9e50599f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Configuration.kt @@ -0,0 +1,109 @@ +package net.rsprot.protocol.api.js5 + +/** + * The configuration for the JS5 service per client basis. + * @property blockSizeInBytes the maximum number of bytes written per client per iteration + * @property flushThresholdInBytes the minimum number of bytes that must be written into + * the channel before a flush occurs. Note that the flush will only occur if at least one + * group has been successfully written over, as there's no point in flushing a partial + * large group - the client cannot continue anyhow. Furthermore, flushing occurs if + * there's no more data to write, ignoring both the [flushThresholdInBytes] and + * [flushThresholdInRequests] thresholds in the process. + * @property flushThresholdInRequests the number of full requests that must be written + * into this channel since the last flush before another flush can trigger. As explained + * before, a flush will take place if no more data can be written out, ignoring this threshold. + * @property priorityRatio the ratio for how much more data to write to logged in clients + * compared to those logged out. A ratio of 3 means that per iteration, any low priority + * client will receive [blockSizeInBytes] number of bytes, however any logged in client + * will receive [blockSizeInBytes] * 3 number of bytes. This effectively gives priority to + * those logged in, as they are in more of a need for data than anyone sitting on the loading + * screen. + * @property prefetchTransferThresholdInBytes the number of bytes to transfer from the 'pending' + * prefetch collection over to the 'being served' prefetch collection. This is a soft cap, + * meaning it will keep transferring groups until the moment that the sum of all transferred + * is equal to or above this value. It is worth noting that this will be uncapped when + * the user reaches the login screen, allowing for fast downloads of the cache if one + * chooses to sit on the login screen. Before reaching the login screen, however, the + * thresholds are still applied. + * Below are some numbers of how long it takes to transfer + * the entire OldSchool cache over via localhost using various thresholds: + * Uncapped - 1 minute, 20 seconds + * 16,384 bytes: 3 minutes, 30 seconds + * 8,192 bytes: 5 minutes, 52 seconds + * 4,096 bytes: 15 minutes, 26 seconds + * + * It is worth noting that prefetch groups are NOT necessary, one could disable them altogether, + * however this would mean the users will experience small loading screens even days into the gameplay. + * Sweet spot is having as small delays as possible when the client requires urgent responses, but + * still downloading the entire thing as soon as possible. 8,192 bytes appears to be that sweet spot. + * @property missingGroupBehaviour the behaviour type of what to do when the server returns a null + * response for a group request. By default, we warn about it (up to 100 times before disabling warnings) + * and drop that request. It should be noted that the client should never normally request for invalid + * groups - if that happens, it was either someone spoofing it, or the server sent something to the client, + * like opening an interface that does not exist, which led to the client making the request. + * In the case of latter, it should be noted that the client may begin to misbehave if it doesn't get + * a reply, particularly if the request it made was for an urgent group. A common side effect of this + * is seeing the "Please wait - loading..." screen indefinitely. + * The default behaviour is to warn the developers about it, with an upper cap of 100 warning messages + * before the warnings get turned off (to avoid flooding and causing performance degradation with logging + * if it is someone spoofing). These warning messages should help developers detect faulty code and + * fix it on the server's end, rather than needing to debug in the client. + */ +public class Js5Configuration public constructor( + public val blockSizeInBytes: Int = 512, + public val flushThresholdInBytes: Int = 10240, + public val flushThresholdInRequests: Int = 10, + public val priorityRatio: Int = 3, + public val prefetchTransferThresholdInBytes: Int = 8192, + public val missingGroupBehaviour: Js5MissingGroupBehaviour = Js5MissingGroupBehaviour.DROP_REQUEST, +) { + init { + require(blockSizeInBytes >= 8) { + "Js5 block size must be at least 8 bytes" + } + require(priorityRatio >= 1) { + "Priority ratio must be at least 1" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Js5Configuration + + if (blockSizeInBytes != other.blockSizeInBytes) return false + if (flushThresholdInBytes != other.flushThresholdInBytes) return false + if (flushThresholdInRequests != other.flushThresholdInRequests) return false + if (priorityRatio != other.priorityRatio) return false + if (prefetchTransferThresholdInBytes != other.prefetchTransferThresholdInBytes) return false + if (missingGroupBehaviour != other.missingGroupBehaviour) return false + + return true + } + + override fun hashCode(): Int { + var result = blockSizeInBytes + result = 31 * result + flushThresholdInBytes + result = 31 * result + flushThresholdInRequests + result = 31 * result + priorityRatio + result = 31 * result + prefetchTransferThresholdInBytes + result = 31 * result + missingGroupBehaviour.hashCode() + return result + } + + override fun toString(): String = + "Js5Configuration(" + + "blockSizeInBytes=$blockSizeInBytes, " + + "flushThresholdInBytes=$flushThresholdInBytes, " + + "flushThresholdInRequests=$flushThresholdInRequests, " + + "priorityRatio=$priorityRatio, " + + "prefetchTransferThresholdInBytes=$prefetchTransferThresholdInBytes, " + + "missingGroupBehaviour=$missingGroupBehaviour" + + ")" + + public enum class Js5MissingGroupBehaviour { + DROP_REQUEST, + DROP_CONNECTION, + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5DisconnectionReason.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5DisconnectionReason.kt new file mode 100644 index 000000000..c2082bee3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5DisconnectionReason.kt @@ -0,0 +1,6 @@ +package net.rsprot.protocol.api.js5 + +public enum class Js5DisconnectionReason { + EXCEPTION, + IDLE, +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5GroupProvider.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5GroupProvider.kt new file mode 100644 index 000000000..e901ee8dc --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5GroupProvider.kt @@ -0,0 +1,19 @@ +package net.rsprot.protocol.api.js5 + +import io.netty.buffer.ByteBuf + +/** + * The group provider interface for JS5. + */ +public fun interface Js5GroupProvider { + /** + * Provides a JS5 group based on the input archive and group + * @param archive the archive id requested by the client + * @param group the group in that archive requested + * @return a full JS5 group to be written to the client + */ + public fun provide( + archive: Int, + group: Int, + ): ByteBuf? +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageDecoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageDecoder.kt new file mode 100644 index 000000000..82adeeeda --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageDecoder.kt @@ -0,0 +1,98 @@ +package net.rsprot.protocol.api.js5 + +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.ByteToMessageDecoder +import io.netty.handler.codec.DecoderException +import net.rsprot.buffer.extensions.g1 +import net.rsprot.buffer.extensions.g2 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.Prot +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.decoder.DecoderState +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository + +/** + * A message decoder for JS5 packets. + */ +@Suppress("DuplicatedCode") +public class Js5MessageDecoder( + public val networkService: NetworkService<*>, +) : ByteToMessageDecoder() { + private val decoders: MessageDecoderRepository = + networkService + .decoderRepositories + .js5MessageDecoderRepository + + private var state: DecoderState = DecoderState.READ_OPCODE + private lateinit var decoder: MessageDecoder<*> + private var opcode: Int = -1 + private var length: Int = 0 + + override fun decode( + ctx: ChannelHandlerContext, + input: ByteBuf, + out: MutableList, + ) { + if (state == DecoderState.READ_OPCODE) { + if (!input.isReadable) { + return + } + this.opcode = input.g1() and 0xFF + this.decoder = decoders.getDecoder(opcode) + this.length = this.decoder.prot.size + state = + if (this.length >= 0) { + DecoderState.READ_PAYLOAD + } else { + DecoderState.READ_LENGTH + } + } + + if (state == DecoderState.READ_LENGTH) { + when (length) { + Prot.VAR_BYTE -> { + if (!input.isReadable(Byte.SIZE_BYTES)) { + return + } + this.length = input.g1() + } + + Prot.VAR_SHORT -> { + if (!input.isReadable(Short.SIZE_BYTES)) { + return + } + this.length = input.g2() + } + + else -> { + throw IllegalStateException("Invalid length: $length of opcode $opcode") + } + } + state = DecoderState.READ_PAYLOAD + } + + if (state == DecoderState.READ_PAYLOAD) { + if (!input.isReadable(length)) { + return + } + val payload = input.readSlice(length) + out += decoder.decode(payload.toJagByteBuf()) + if (payload.isReadable) { + throw DecoderException( + "Decoder ${decoder.javaClass} did not read entire payload " + + "of opcode $opcode: ${payload.readableBytes()}", + ) + } + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .incrementIncomingPackets(ctx.hostAddress(), opcode, length) + + state = DecoderState.READ_OPCODE + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageEncoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageEncoder.kt new file mode 100644 index 000000000..568470db3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5MessageEncoder.kt @@ -0,0 +1,102 @@ +package net.rsprot.protocol.api.js5 + +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelPromise +import io.netty.handler.codec.EncoderException +import io.netty.util.ReferenceCountUtil +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.cipher.NopStreamCipher +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.encoder.OutgoingMessageEncoder +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.common.js5.outgoing.prot.Js5ServerProt +import net.rsprot.protocol.js5.outgoing.Js5GroupResponse +import net.rsprot.protocol.message.OutgoingMessage +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository + +/** + * A message encoder for JS5 requests. + */ +public class Js5MessageEncoder( + public val networkService: NetworkService<*>, +) : OutgoingMessageEncoder() { + override val cipher: StreamCipher = NopStreamCipher + override val repository: MessageEncoderRepository<*> = + networkService.encoderRepositories.js5MessageEncoderRepository + override val validate: Boolean = false + override val estimator: OutgoingMessageSizeEstimator = networkService.messageSizeEstimator + + override fun write( + ctx: ChannelHandlerContext, + msg: Any, + promise: ChannelPromise, + ) { + var buf: ByteBuf? = null + try { + if (msg is Js5GroupResponse) { + if (msg.key != 0) { + // If an encryption key is used, we allocate a new buffer and follow the normal flow + buf = allocateBuffer(ctx, msg) + try { + encode(ctx, msg, buf) + } finally { + ReferenceCountUtil.release(msg) + } + if (buf.isReadable) { + ctx.write(buf, promise) + } else { + buf.release() + ctx.write(Unpooled.EMPTY_BUFFER, promise) + } + buf = null + } else { + // If no encryption key is used, we simply pass on the same JS5 byte buffer + // instead of needing to copy it to a new buffer + buf = msg.content() + + // We _only_ call the encode function to trigger our logging, the function + // itself will not be doing any encoding if key is zero. + encode(ctx, msg, buf) + + if (buf!!.isReadable) { + ctx.write(buf, promise) + } else { + buf.release() + ctx.write(Unpooled.EMPTY_BUFFER, promise) + } + buf = null + } + } else { + ctx.write(msg, promise) + } + } catch (e: EncoderException) { + throw e + } catch (t: Throwable) { + throw EncoderException(t) + } finally { + buf?.release() + } + } + + override fun encode( + ctx: ChannelHandlerContext, + msg: OutgoingMessage, + out: ByteBuf, + ) { + val encoder = repository.getEncoder(msg::class.java) + encoder.encode( + cipher, + out.toJagByteBuf(), + msg, + ) + val writtenBytes = out.readableBytes() + networkService + .trafficMonitor + .js5ChannelTrafficMonitor + .incrementOutgoingPacketPayload(ctx.hostAddress(), Js5ServerProt.JS5_GROUP_RESPONSE.opcode, writtenBytes) + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Service.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Service.kt new file mode 100644 index 000000000..a192eac59 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/Js5Service.kt @@ -0,0 +1,363 @@ +package net.rsprot.protocol.api.js5 + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.js5.util.UniqueQueue +import net.rsprot.protocol.api.logging.js5Log +import net.rsprot.protocol.js5.incoming.Js5GroupRequest +import net.rsprot.protocol.js5.outgoing.Js5GroupResponse +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.math.min + +/** + * A single-threaded JS5 service implementation used to fairly feed + * all connected clients, with a priority on those in the logged in state. + * @property configuration the configuration to use for writing the data to clients + * @property provider the provider for JS5 groups to write over + * @property authorizer the js5 authorizer that will reject attempts at downloading + * protected beta cache archives. + */ +public class Js5Service( + private val networkService: NetworkService<*>, + private val configuration: Js5Configuration, + private val provider: Js5GroupProvider, + private val authorizer: Js5Authorizer, +) : Runnable { + private val clients = UniqueQueue() + private val connectedClients = ArrayDeque() + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + @PublishedApi + internal val lock: Object = Object() + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + private val clientLock: Object = Object() + + @Volatile + private var isRunning: Boolean = true + + @Volatile + private var cachedMasterIndex: ByteArray? = null + + public fun getMasterIndex(): ByteArray { + val cached = cachedMasterIndex + if (cached != null) { + return cached + } + val group = + provider.provide(0xFF, 0xFF) + ?: error("JS5 master index unavailable.") + val array = ByteArray(group.readableBytes()) + group.getBytes(group.readerIndex(), array) + this.cachedMasterIndex = array + return array + } + + override fun run() { + while (true) { + try { + var client: Js5Client + var response: Js5GroupResponse + var flush: Boolean + synchronized(lock) { + while (true) { + if (!isRunning) { + return + } + val next = clients.removeFirstOrNull() + if (next == null) { + lock.wait() + continue + } + client = next + if (!client.ctx.channel().isActive) { + continue + } + val priority = client.priority + val ratio = + if (priority == Js5Client.ClientPriority.HIGH) { + configuration.priorityRatio + } else { + 1 + } + try { + response = client.getNextBlock( + networkService, + authorizer, + configuration.missingGroupBehaviour, + provider, + configuration.blockSizeInBytes * ratio, + ) ?: continue + } catch (t: Throwable) { + logger.warn(t) { + "Unable to serve channel '${client.ctx.channel()}', dropping connection." + } + client.ctx.close() + continue + } + flush = + client.needsFlushing( + configuration.flushThresholdInBytes, + configuration.flushThresholdInRequests, + ) + if (flush) { + client.resetTracker() + } + break + } + } + + try { + serveClient(client, response, flush) + } catch (t: Throwable) { + logger.error(t) { + "Unable to serve channel ${client.ctx.channel()}, dropping connection." + } + client.ctx.close() + } + } catch (t: Throwable) { + logger.error(t) { + "Error in JS5 service processing - JS5 service has been killed." + } + throw t + } + } + } + + private fun prefetch(): Runnable = + Runnable { + // Ensure the connectedClients collection doesn't modify during it, as modifications + // during iteration may not occur + synchronized(clientLock) { + for (client in connectedClients) { + // Obtain a short-lived lock to avoid blocking the service for long periods + // This is to ensure we don't run into concurrency issues. + synchronized(lock) { + if (client.transferPrefetch( + provider, + configuration.prefetchTransferThresholdInBytes, + ) + ) { + clients.add(client) + lock.notifyAll() + if (client.isNotFull()) { + client.ctx.read() + } + } + } + } + } + } + + internal fun onClientConnected(client: Js5Client) { + synchronized(clientLock) { + this.connectedClients += client + } + } + + internal fun onClientDisconnected(client: Js5Client) { + synchronized(clientLock) { + this.connectedClients -= client + } + } + + /** + * Serves a client with a jS5 response which may only be a subsection of a full group. + * @param client the client to serve + * @param response the response to write to the client + * @param flush whether to flush the channel after writing this request + */ + private fun serveClient( + client: Js5Client, + response: Js5GroupResponse, + flush: Boolean, + ) { + val ctx = client.ctx + ctx.write(response) + js5Log(logger) { + "Serving channel '${ctx.channel()}' with response: $response" + } + if (flush) { + js5Log(logger) { + "Flushing channel ${ctx.channel()}" + } + ctx.flush() + } + synchronized(lock) { + if (!flush || client.isReady()) { + js5Log(logger) { + "Continuing to serve channel ${ctx.channel()}" + } + clients.add(client) + } else { + js5Log(logger) { + "No longer serving channel ${ctx.channel()}" + } + } + + val notFull = client.isNotFull() + if (notFull) { + ctx.read() + } + js5Log(logger) { + "Reading further JS5 requests from channel ${ctx.channel()}" + } + } + } + + /** + * Pushes a new JS5 request to this client + * @param client the client to push the request to + * @param request the request to push to this client + */ + public fun push( + client: Js5Client, + request: Js5GroupRequest, + ) { + synchronized(lock) { + client.push(request) + + if (client.isReady()) { + clients.add(client) + lock.notifyAll() + } + + if (client.isNotFull()) { + client.ctx.read() + } + } + } + + /** + * Requests a read from the given channel if it can receive more requests. + * @param client the client to check + */ + public fun readIfNotFull(client: Js5Client) { + synchronized(lock) { + if (client.isNotFull()) { + js5Log(logger) { + "Reading further JS5 requests from channel ${client.ctx.channel()}" + } + client.ctx.read() + } + } + } + + /** + * Notifies the lock if the list of clients is not empty, resuming the JS5 + * service in the process. + */ + public fun notifyIfNotEmpty(client: Js5Client) { + synchronized(lock) { + if (client.isNotEmpty()) { + js5Log(logger) { + "Channel '${client.ctx.channel()}' is now writable, continuing to serve JS5 requests." + } + clients.add(client) + lock.notifyAll() + } + } + } + + /** + * Executes the [block] in a synchronized manner as the rest of the JS5. + */ + @PublishedApi + internal inline fun use(block: () -> Unit) { + synchronized(lock) { + block() + } + } + + /** + * Triggers a shutdown. + */ + public fun triggerShutdown() { + isRunning = false + synchronized(lock) { + lock.notifyAll() + } + } + + public companion object { + /** + * The interval at which a terminator byte is expected in the client. + */ + internal const val BLOCK_LENGTH: Int = 512 + private val logger: InlineLogger = InlineLogger() + + /** + * Prepares a JS5 buffer to be in a format read to be served to the clients, + * by splitting the payload up into chunks of 512 bytes, which each have a 0xFF + * terminator splitting them. + * @param archive the archive id, written as a byte at the start + * @param group the group id, written as a short at the start + * @param input the input byte buffer from the cache, with version information + * stripped off + * @param output the output byte buffer into which to write the split-up JS5 buffers. + */ + public fun prepareJs5Buffer( + archive: Int, + group: Int, + input: ByteBuf, + output: ByteBuf, + ) { + val readableBytes = input.readableBytes() + output.writeByte(archive) + output.writeShort(group) + // Block length - 3 as we already wrote 3 bytes at the start + val len = min(readableBytes, BLOCK_LENGTH - 3) + output.writeBytes(input, 0, len) + + var offset = len + while (offset < readableBytes) { + output.writeByte(0xFF) + // Block length - 1 as we already wrote the separator 0xFF + val nextBlockLength = min(readableBytes - offset, BLOCK_LENGTH - 1) + output.writeBytes(input, offset, nextBlockLength) + offset += nextBlockLength + } + } + + /** + * Ensures that the input buffer has been correctly sliced up. + * We run this validation on the first JS5 response we receive that is at least [BLOCK_LENGTH] + * in size. While it is possible that the buffer isn't correctly sliced up, but just happens + * to have 0xFF at the right positions, it's very unlikely. + * This simply offers a little extra warning for people trying to implement the JS5 system, + * giving a clear indication that they've not called the necessary functions in order to + * prepare the JS5 buffers ahead of time. + * @param buffer the byte buffer to check for valid terminators. + * @return whether the byte buffer has been correctly sliced up. + */ + internal fun ensureCorrectlySliced(buffer: ByteBuf): Boolean { + val cap = buffer.readableBytes() + for (i in BLOCK_LENGTH.. + thread(start = false, name = "Js5 Executor Service (prefetch)") { + block.run() + } + } + executor.scheduleWithFixedDelay( + service.prefetch(), + 200, + 200, + TimeUnit.MILLISECONDS, + ) + return executor + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/NoopJs5Authorizer.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/NoopJs5Authorizer.kt new file mode 100644 index 000000000..7da478963 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/NoopJs5Authorizer.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.api.js5 + +/** + * A no-op implementation of the JS5 authorizer. + * This implementation ignores all requests and accepts any requests made. + */ +public data object NoopJs5Authorizer : Js5Authorizer { + override fun authorize(address: String) { + } + + override fun unauthorize(address: String) { + } + + override fun isAuthorized( + address: String, + archive: Int, + ): Boolean { + return true + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/IntArrayDeque.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/IntArrayDeque.kt new file mode 100644 index 000000000..04c356708 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/IntArrayDeque.kt @@ -0,0 +1,634 @@ +package net.rsprot.protocol.api.js5.util + +/** + * A specialized array deque for int types, allowing no-autoboxing implementation. + * This is effectively just the Kotlin stdlib ArrayDeque class copied over and adjusted + * to work off of the primitive int. + */ +@Suppress("ReplaceUntilWithRangeUntil", "NOTHING_TO_INLINE", "MemberVisibilityCanBePrivate") +public class IntArrayDeque { + private var head: Int = 0 + private var elementData: IntArray + + public var size: Int = 0 + private set + + public val lastIndex: Int + get() = elementData.size - 1 + + /** + * Constructs an empty deque with specified [initialCapacity], or throws [IllegalArgumentException] if [initialCapacity] is negative. + */ + public constructor( + initialCapacity: Int, + ) { + elementData = + when { + initialCapacity == 0 -> emptyElementData + initialCapacity > 0 -> IntArray(initialCapacity) + else -> throw IllegalArgumentException("Illegal Capacity: $initialCapacity") + } + } + + /** + * Constructs an empty deque. + */ + public constructor() { + elementData = emptyElementData + } + + /** + * Constructs a deque that contains the same elements as the specified [elements] collection in the same order. + */ + public constructor( + elements: Collection, + ) { + elementData = elements.toIntArray() + size = elementData.size + if (elementData.isEmpty()) elementData = emptyElementData + } + + /** + * Ensures that the capacity of this deque is at least equal to the specified [minCapacity]. + * + * If the current capacity is less than the [minCapacity], a new backing storage is allocated with greater capacity. + * Otherwise, this method takes no action and simply returns. + */ + private fun ensureCapacity(minCapacity: Int) { + if (minCapacity < 0) throw IllegalStateException("Deque is too big.") // overflow + if (minCapacity <= elementData.size) return + if (elementData === emptyElementData) { + elementData = IntArray(minCapacity.coerceAtLeast(DEFAULT_MIN_CAPACITY)) + return + } + + val newCapacity = newCapacity(elementData.size, minCapacity) + copyElements(newCapacity) + } + + /** + * Creates a new array with the specified [newCapacity] size and copies elements in the [elementData] array to it. + */ + private fun copyElements(newCapacity: Int) { + val newElements = IntArray(newCapacity) + elementData.copyInto(newElements, 0, head, elementData.size) + elementData.copyInto(newElements, elementData.size - head, 0, head) + head = 0 + elementData = newElements + } + + private inline fun internalGet(internalIndex: Int): Int = elementData[internalIndex] + + private fun positiveMod(index: Int): Int = if (index >= elementData.size) index - elementData.size else index + + private fun negativeMod(index: Int): Int = if (index < 0) index + elementData.size else index + + private inline fun internalIndex(index: Int): Int = positiveMod(head + index) + + private fun incremented(index: Int): Int = if (index == elementData.lastIndex) 0 else index + 1 + + private fun decremented(index: Int): Int = if (index == 0) elementData.lastIndex else index - 1 + + public fun isEmpty(): Boolean = size == 0 + + public inline fun isNotEmpty(): Boolean = !isEmpty() + + /** + * Returns the first element, or throws [NoSuchElementException] if this deque is empty. + */ + public fun first(): Int = if (isEmpty()) throw NoSuchElementException("ArrayDeque is empty.") else internalGet(head) + + /** + * Returns the first element, or `null` if this deque is empty. + */ + public fun firstOrNull(): Int? = if (isEmpty()) null else internalGet(head) + + /** + * Returns the last element, or throws [NoSuchElementException] if this deque is empty. + */ + public fun last(): Int = + if (isEmpty()) throw NoSuchElementException("ArrayDeque is empty.") else internalGet(internalIndex(lastIndex)) + + /** + * Returns the last element, or `null` if this deque is empty. + */ + public fun lastOrNull(): Int? = if (isEmpty()) null else internalGet(internalIndex(lastIndex)) + + /** + * Prepends the specified [element] to this deque. + */ + public fun addFirst(element: Int) { + ensureCapacity(size + 1) + + head = decremented(head) + elementData[head] = element + size += 1 + } + + /** + * Appends the specified [element] to this deque. + */ + public fun addLast(element: Int) { + ensureCapacity(size + 1) + + elementData[internalIndex(size)] = element + size += 1 + } + + /** + * Removes the first element from this deque and returns that removed element, or throws [NoSuchElementException] if this deque is empty. + */ + public fun removeFirst(): Int { + if (isEmpty()) throw NoSuchElementException("ArrayDeque is empty.") + + val element = internalGet(head) + elementData[head] = 0 + head = incremented(head) + size -= 1 + return element + } + + /** + * Removes the first element from this deque and returns that removed element, or returns `null` if this deque is empty. + */ + public fun removeFirstOrNull(): Int? = if (isEmpty()) null else removeFirst() + + /** + * Removes the first element from this deque and returns that removed element, + * or returns [default] if this deque is empty. + */ + public fun removeFirstOrDefault(default: Int): Int = if (isEmpty()) default else removeFirst() + + /** + * Removes the last element from this deque and returns that removed element, or throws [NoSuchElementException] if this deque is empty. + */ + public fun removeLast(): Int { + if (isEmpty()) throw NoSuchElementException("ArrayDeque is empty.") + + val internalLastIndex = internalIndex(lastIndex) + val element = internalGet(internalLastIndex) + elementData[internalLastIndex] = 0 + size -= 1 + return element + } + + /** + * Removes the last element from this deque and returns that removed element, or returns `null` if this deque is empty. + */ + public fun removeLastOrNull(): Int? = if (isEmpty()) null else removeLast() + + // MutableList, MutableCollection + public fun add(element: Int): Boolean { + addLast(element) + return true + } + + public fun add( + index: Int, + element: Int, + ) { + checkPositionIndex(index, size) + + if (index == size) { + addLast(element) + return + } else if (index == 0) { + addFirst(element) + return + } + + ensureCapacity(size + 1) + + // Elements in circular array lay in 2 ways: + // 1. `head` is less than `tail`: [#, #, e1, e2, e3, #] + // 2. `head` is greater than `tail`: [e3, #, #, #, e1, e2] + // where head is the index of the first element in the circular array, + // and tail is the index following the last element. + // + // At this point the insertion index is not equal to head or tail. + // Also the circular array can store at least one more element. + // + // Depending on where the given element must be inserted the preceding or the succeeding + // elements will be shifted to make room for the element to be inserted. + // + // In case the preceding elements are shifted: + // * if the insertion index is greater than the head (regardless of circular array form) + // -> shift the preceding elements + // * otherwise, the circular array has (2) form and the insertion index is less than tail + // -> shift all elements in the back of the array + // -> shift preceding elements in the front of the array + // In case the succeeding elements are shifted: + // * if the insertion index is less than the tail (regardless of circular array form) + // -> shift the succeeding elements + // * otherwise, the circular array has (2) form and the insertion index is greater than head + // -> shift all elements in the front of the array + // -> shift succeeding elements in the back of the array + + val internalIndex = internalIndex(index) + + if (index < (size + 1) shr 1) { + // closer to the first element -> shift preceding elements + val decrementedInternalIndex = decremented(internalIndex) + val decrementedHead = decremented(head) + + if (decrementedInternalIndex >= head) { + elementData[decrementedHead] = elementData[head] // head can be zero + elementData.copyInto(elementData, head, head + 1, decrementedInternalIndex + 1) + } else { // head > tail + elementData.copyInto(elementData, head - 1, head, elementData.size) // head can't be zero + elementData[elementData.size - 1] = elementData[0] + elementData.copyInto(elementData, 0, 1, decrementedInternalIndex + 1) + } + + elementData[decrementedInternalIndex] = element + head = decrementedHead + } else { + // closer to the last element -> shift succeeding elements + val tail = internalIndex(size) + + if (internalIndex < tail) { + elementData.copyInto(elementData, internalIndex + 1, internalIndex, tail) + } else { // head > tail + elementData.copyInto(elementData, 1, 0, tail) + elementData[0] = elementData[elementData.size - 1] + elementData.copyInto(elementData, internalIndex + 1, internalIndex, elementData.size - 1) + } + + elementData[internalIndex] = element + } + size += 1 + } + + private fun copyCollectionElements( + internalIndex: Int, + elements: Collection, + ) { + val iterator = elements.iterator() + + for (index in internalIndex until elementData.size) { + if (!iterator.hasNext()) break + elementData[index] = iterator.next() + } + for (index in 0 until head) { + if (!iterator.hasNext()) break + elementData[index] = iterator.next() + } + + size += elements.size + } + + public fun addAll(elements: Collection): Boolean { + if (elements.isEmpty()) return false + ensureCapacity(this.size + elements.size) + copyCollectionElements(internalIndex(size), elements) + return true + } + + public fun addAll( + index: Int, + elements: Collection, + ): Boolean { + checkPositionIndex(index, size) + + if (elements.isEmpty()) { + return false + } else if (index == size) { + return addAll(elements) + } + + ensureCapacity(this.size + elements.size) + + val tail = internalIndex(size) + val internalIndex = internalIndex(index) + val elementsSize = elements.size + + if (index < (size + 1) shr 1) { + // closer to the first element -> shift preceding elements + + var shiftedHead = head - elementsSize + + if (internalIndex >= head) { + if (shiftedHead >= 0) { + elementData.copyInto(elementData, shiftedHead, head, internalIndex) + } else { // head < tail, insertion leads to head >= tail + shiftedHead += elementData.size + val elementsToShift = internalIndex - head + val shiftToBack = elementData.size - shiftedHead + + if (shiftToBack >= elementsToShift) { + elementData.copyInto(elementData, shiftedHead, head, internalIndex) + } else { + elementData.copyInto(elementData, shiftedHead, head, head + shiftToBack) + elementData.copyInto(elementData, 0, head + shiftToBack, internalIndex) + } + } + } else { // head > tail, internalIndex < tail + elementData.copyInto(elementData, shiftedHead, head, elementData.size) + if (elementsSize >= internalIndex) { + elementData.copyInto(elementData, elementData.size - elementsSize, 0, internalIndex) + } else { + elementData.copyInto(elementData, elementData.size - elementsSize, 0, elementsSize) + elementData.copyInto(elementData, 0, elementsSize, internalIndex) + } + } + head = shiftedHead + copyCollectionElements(negativeMod(internalIndex - elementsSize), elements) + } else { + // closer to the last element -> shift succeeding elements + + val shiftedInternalIndex = internalIndex + elementsSize + + if (internalIndex < tail) { + if (tail + elementsSize <= elementData.size) { + elementData.copyInto(elementData, shiftedInternalIndex, internalIndex, tail) + } else { // head < tail, insertion leads to head >= tail + if (shiftedInternalIndex >= elementData.size) { + elementData.copyInto(elementData, shiftedInternalIndex - elementData.size, internalIndex, tail) + } else { + val shiftToFront = tail + elementsSize - elementData.size + elementData.copyInto(elementData, 0, tail - shiftToFront, tail) + elementData.copyInto(elementData, shiftedInternalIndex, internalIndex, tail - shiftToFront) + } + } + } else { // head > tail, internalIndex > head + elementData.copyInto(elementData, elementsSize, 0, tail) + if (shiftedInternalIndex >= elementData.size) { + elementData.copyInto( + elementData, + shiftedInternalIndex - elementData.size, + internalIndex, + elementData.size, + ) + } else { + elementData.copyInto(elementData, 0, elementData.size - elementsSize, elementData.size) + elementData.copyInto( + elementData, + shiftedInternalIndex, + internalIndex, + elementData.size - elementsSize, + ) + } + } + copyCollectionElements(internalIndex, elements) + } + + return true + } + + public fun get(index: Int): Int { + checkElementIndex(index, size) + + return internalGet(internalIndex(index)) + } + + public fun set( + index: Int, + element: Int, + ): Int { + checkElementIndex(index, size) + + val internalIndex = internalIndex(index) + val oldElement = internalGet(internalIndex) + elementData[internalIndex] = element + + return oldElement + } + + public fun contains(element: Int): Boolean = indexOf(element) != -1 + + public fun indexOf(element: Int): Int { + val tail = internalIndex(size) + + if (head < tail) { + for (index in head until tail) { + if (element == elementData[index]) return index - head + } + } else { + for (index in head until elementData.size) { + if (element == elementData[index]) return index - head + } + for (index in 0 until tail) { + if (element == elementData[index]) return index + elementData.size - head + } + } + + return -1 + } + + public fun lastIndexOf(element: Int): Int { + val tail = internalIndex(size) + + if (head < tail) { + for (index in tail - 1 downTo head) { + if (element == elementData[index]) return index - head + } + } else if (head > tail) { + for (index in tail - 1 downTo 0) { + if (element == elementData[index]) return index + elementData.size - head + } + for (index in elementData.lastIndex downTo head) { + if (element == elementData[index]) return index - head + } + } + + return -1 + } + + public fun remove(element: Int): Boolean { + val index = indexOf(element) + if (index == -1) return false + removeAt(index) + return true + } + + public fun removeAt(index: Int): Int { + checkElementIndex(index, size) + + if (index == lastIndex) { + return removeLast() + } else if (index == 0) { + return removeFirst() + } + + val internalIndex = internalIndex(index) + val element = internalGet(internalIndex) + + if (index < size shr 1) { + // closer to the first element -> shift preceding elements + if (internalIndex >= head) { + elementData.copyInto(elementData, head + 1, head, internalIndex) + } else { // head > tail, internalIndex < head + elementData.copyInto(elementData, 1, 0, internalIndex) + elementData[0] = elementData[elementData.size - 1] + elementData.copyInto(elementData, head + 1, head, elementData.size - 1) + } + + elementData[head] = 0 + head = incremented(head) + } else { + // closer to the last element -> shift succeeding elements + val internalLastIndex = internalIndex(lastIndex) + + if (internalIndex <= internalLastIndex) { + elementData.copyInto(elementData, internalIndex, internalIndex + 1, internalLastIndex + 1) + } else { // head > tail, internalIndex > head + elementData.copyInto(elementData, internalIndex, internalIndex + 1, elementData.size) + elementData[elementData.size - 1] = elementData[0] + elementData.copyInto(elementData, 0, 1, internalLastIndex + 1) + } + + elementData[internalLastIndex] = 0 + } + size -= 1 + + return element + } + + public fun removeAll(elements: Collection): Boolean = filterInPlace { !elements.contains(it) } + + public fun retainAll(elements: Collection): Boolean = filterInPlace { elements.contains(it) } + + private inline fun filterInPlace(predicate: (Int) -> Boolean): Boolean { + if (this.isEmpty() || elementData.isEmpty()) { + return false + } + + val tail = internalIndex(size) + var newTail = head + var modified = false + + if (head < tail) { + for (index in head until tail) { + val element = elementData[index] + + if (predicate(element)) { + elementData[newTail++] = element + } else { + modified = true + } + } + + elementData.fill(0, newTail, tail) + } else { + for (index in head until elementData.size) { + val element = elementData[index] + elementData[index] = 0 + + if (predicate(element)) { + elementData[newTail++] = element + } else { + modified = true + } + } + + newTail = positiveMod(newTail) + + for (index in 0 until tail) { + val element = elementData[index] + elementData[index] = 0 + + if (predicate(element)) { + elementData[newTail] = element + newTail = incremented(newTail) + } else { + modified = true + } + } + } + if (modified) { + size = negativeMod(newTail - head) + } + + return modified + } + + public fun clear() { + val tail = internalIndex(size) + if (head < tail) { + elementData.fill(0, head, tail) + } else if (isNotEmpty()) { + elementData.fill(0, head, elementData.size) + elementData.fill(0, 0, tail) + } + head = 0 + size = 0 + } + + @Suppress("NOTHING_TO_OVERRIDE") + public fun toArray(array: IntArray): IntArray { + val dest = ( + if (array.size >= size) { + array + } else { + IntArray(size) + } + ) + + val tail = internalIndex(size) + if (head < tail) { + elementData.copyInto(dest, startIndex = head, endIndex = tail) + } else if (isNotEmpty()) { + elementData.copyInto(dest, destinationOffset = 0, startIndex = head, endIndex = elementData.size) + elementData.copyInto(dest, destinationOffset = elementData.size - head, startIndex = 0, endIndex = tail) + } + + return array + } + + @Suppress("NOTHING_TO_OVERRIDE") + public fun toArray(): IntArray = toArray(IntArray(size)) + + internal companion object { + private val emptyElementData = IntArray(0) + private const val DEFAULT_MIN_CAPACITY = 10 + + internal fun checkElementIndex( + index: Int, + size: Int, + ) { + if (index < 0 || index >= size) { + throw IndexOutOfBoundsException("index: $index, size: $size") + } + } + + internal fun checkPositionIndex( + index: Int, + size: Int, + ) { + if (index < 0 || index > size) { + throw IndexOutOfBoundsException("index: $index, size: $size") + } + } + + /** + * The max capacity for this specialized int array deque. + * The limit comes from the fact a single queue can only be + * up to length 200, as that is the most concurrent requests the + * client will ever send out. We keep one for headroom/partial + * processing. + */ + private const val MAX_CAPACITY = 201 + + /** [oldCapacity] and [minCapacity] must be non-negative. */ + internal fun newCapacity( + oldCapacity: Int, + minCapacity: Int, + ): Int { + // overflow-conscious + var newCapacity = oldCapacity + (oldCapacity shr 1) + if (newCapacity - minCapacity < 0) { + newCapacity = minCapacity + } + if (newCapacity > MAX_CAPACITY) { + if (minCapacity > MAX_CAPACITY) { + throw IllegalStateException( + "Required array size $minCapacity exceeds " + + "max supported capacity ($MAX_CAPACITY).", + ) + } + newCapacity = MAX_CAPACITY + } + return newCapacity + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/UniqueQueue.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/UniqueQueue.kt new file mode 100644 index 000000000..c082209c5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/js5/util/UniqueQueue.kt @@ -0,0 +1,51 @@ +package net.rsprot.protocol.api.js5.util + +/** + * A unique ArrayDeque that utilizes a hash set to check whether something already exists + * in the queue. + * This implementation is NOT thread-safe. + * @property queue the backing array deque + * @property set the hash set used to check if something exists in this queue + */ +public class UniqueQueue : Iterable { + private val queue = ArrayDeque() + private val set = HashSet() + + /** + * Adds the element [v] into this queue if it isn't already in the hash set. + * @return true if the element was added + */ + public fun add(v: T): Boolean { + if (set.add(v)) { + queue.addLast(v) + return true + } + + return false + } + + /** + * Removes the first element from this queue, or null if it doesn't exist. + * If it does exist, the element is furthermore removed from the backing hash set. + * @return element if it exists, else null + */ + public fun removeFirstOrNull(): T? { + val v = queue.removeFirstOrNull() + if (v != null) { + set.remove(v) + return v + } + + return null + } + + /** + * Clears both the queue and the set. + */ + public fun clear() { + queue.clear() + set.clear() + } + + override fun iterator(): Iterator = queue.iterator() +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/logging/LoggingExt.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/logging/LoggingExt.kt new file mode 100644 index 000000000..ebd7bad8b --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/logging/LoggingExt.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.api.logging + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.internal.LogLevel +import net.rsprot.protocol.internal.RSProtFlags + +/** + * Performs a debug log if network logging flag is enabled. + * This effectively allows one to apply a second filter as network stuff + * is fairly high pressure. Furthermore, since the logger level checks + * actually consist of quite a lot of function calls and checks, + * they're not as cheap as one might expect, so running a preliminary + * boolean check on it beforehand avoids doing any of that work, meaning + * this should have virtually no effect in production if disabled. + */ +internal inline fun networkLog( + logger: InlineLogger, + block: () -> Any?, +) { + logBlock(logger, RSProtFlags.networkLogging, block) +} + +/** + * Performs a debug log if JS5 logging flag is enabled. + * This effectively allows one to apply a second filter as JS5 logging + * is extremely high pressure. Furthermore, since the logger level checks + * actually consist of quite a lot of function calls and checks, + * they're not as cheap as one might expect, so running a preliminary + * boolean check on it beforehand avoids doing any of that work, meaning + * this should have virtually no effect in production if disabled. + */ +internal inline fun js5Log( + logger: InlineLogger, + block: () -> Any?, +) { + logBlock(logger, RSProtFlags.js5Logging, block) +} + +private inline fun logBlock( + logger: InlineLogger, + level: LogLevel, + block: () -> Any?, +) { + when (level) { + LogLevel.OFF -> { + // no-op + } + LogLevel.TRACE -> { + logger.trace(block) + } + LogLevel.DEBUG -> { + logger.debug(block) + } + LogLevel.INFO -> { + logger.info(block) + } + LogLevel.WARN -> { + logger.warn(block) + } + LogLevel.ERROR -> { + logger.error(block) + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/GameLoginResponseHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/GameLoginResponseHandler.kt new file mode 100644 index 000000000..379fdb23f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/GameLoginResponseHandler.kt @@ -0,0 +1,360 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.api.login + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.Unpooled +import io.netty.buffer.UnpooledByteBufAllocator +import io.netty.channel.Channel +import io.netty.channel.ChannelFutureListener +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelPipeline +import io.netty.handler.timeout.IdleStateHandler +import net.rsprot.buffer.extensions.gdata +import net.rsprot.buffer.extensions.p8 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.crypto.cipher.StreamCipherPair +import net.rsprot.protocol.Prot +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.Session +import net.rsprot.protocol.api.game.GameMessageDecoder +import net.rsprot.protocol.api.game.GameMessageEncoder +import net.rsprot.protocol.api.game.GameMessageHandler +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.binary.BinaryBlob +import net.rsprot.protocol.binary.BinaryStream +import net.rsprot.protocol.channel.binaryHeaderBuilderOrNull +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.channel.replace +import net.rsprot.protocol.channel.setBinaryBlob +import net.rsprot.protocol.channel.setBinaryHeaderBuilder +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import java.security.MessageDigest + +/** + * A response handler for login requests, allowing the server to write either + * a successful or a failed login response, depending on the server's decision. + * @property networkService the main network service god object + * @property ctx the channel handler context to write the response to + */ +public class GameLoginResponseHandler( + public val networkService: NetworkService, + public val ctx: ChannelHandlerContext, +) { + /** + * Validates the new connection by ensuring the connected user hasn't reached an IP limitation due + * to too many connections from the same IP. + * This function does not write any response to the client. It simply returns whether a new connection + * is allowed to take place. The server is responsible for writing the [LoginResponse.TooManyAttempts] + * response back to the client via [writeFailedResponse], should they wish to do so. + */ + public fun validateNewConnection(): Boolean { + val address = ctx.hostAddress() + val count = + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .getCount(address) + return networkService + .iNetAddressHandlers + .inetAddressValidator + .acceptGameConnection(address, count) + } + + /** + * Writes a successful login response to the client. + * @param response the login response to write + * @param loginBlock the login request that the client initially made + * @return a session object regardless of if the connection is still alive. If the connection has died, + * the disconnection hook will be triggered immediately upon being assigned. + */ + public fun writeSuccessfulResponse( + response: LoginResponse.Ok, + loginBlock: LoginBlock<*>, + ): Session { + // Ensure it isn't null - our decoder pre-validates it long before hitting this function, + // so this exception should never be hit. + val oldSchoolClientType = + checkNotNull(loginBlock.clientType.toOldSchoolClientType()) { + "Login client type cannot be null" + } + val cipher = createStreamCipherPair(loginBlock) + val encoder = + networkService + .encoderRepositories + .loginMessageEncoderRepository + .getEncoder(response::class.java) + + // Special logic here due to beta worlds having a special login flow, and Netty 4.2.RC3 doing + // a breaking change which gave each pipeline handler its own executor. The fact the executors + // are no longer the same requires us to delicately write the data out in a predictable manner. + + // See: https://github.com/netty/netty/pull/14705 + // > This also means that some code now moves from the executor of the target context, + // > to the executor of the calling context. This can create different behaviors from Netty 4.1, + // > if the pipeline has multiple handlers, is modified by the handlers during the call, + // > and the handlers use child-executors. + + // This issue was experienced in production by having LoginResponse.Ok arrive after certain + // game packets, due to the executor differing and race conditions taking place. + + val buffer = ctx.alloc().buffer(37).toJagByteBuf() + if (!networkService.betaWorld) { + buffer.p1(encoder.prot.opcode) + } + // Client expects a hardcoded 37 value for the size, even though it is not the exact size + // of the login packet + buffer.p1(37) + encoder.encode(cipher.encoderCipher, buffer, response) + + val pipeline = ctx.channel().pipeline() + finalizeBinaryHeader(ctx.channel(), response) + val session = + createSession(loginBlock, pipeline, cipher.decodeCipher, oldSchoolClientType, cipher.encoderCipher) + networkService.js5Authorizer.authorize(ctx.hostAddress()) + ctx.executor().submit { + ctx.write(buffer.buffer) + session.onLoginTransitionComplete() + } + networkLog(logger) { + "Successful game login from channel '${ctx.channel()}': $loginBlock" + } + return session + } + + private fun finalizeBinaryHeader( + channel: Channel, + response: LoginResponse.Ok, + ) { + val provider = networkService.binaryHeaderProvider ?: return + val builder = channel.binaryHeaderBuilderOrNull() ?: return + channel.setBinaryHeaderBuilder(null) + val timestamp = System.currentTimeMillis() + val accountHash = accountHash(response.userId, response.userHash) + val partialHeader = + provider.provide(response.index, timestamp, accountHash) + ?: return + builder.timestamp(timestamp) + builder.localPlayerIndex(response.index) + builder.accountHash(accountHash) + builder.path(partialHeader.path) + builder.worldId(partialHeader.worldId) + builder.worldProperties(partialHeader.worldFlags) + builder.worldLocation(partialHeader.worldLocation) + builder.worldHost(partialHeader.worldHost) + builder.worldActivity(partialHeader.worldActivity) + builder.clientName(partialHeader.clientName) + val masterIndex = networkService.js5Service.getMasterIndex() + builder.js5MasterIndex(masterIndex) + val header = builder.build() + channel.setBinaryBlob( + BinaryBlob( + header, + BinaryStream(header.encode(UnpooledByteBufAllocator.DEFAULT)), + ), + ) + } + + private fun accountHash( + userId: Long, + userHash: Long, + ): ByteArray { + val buffer = Unpooled.buffer(Long.SIZE_BYTES + Long.SIZE_BYTES) + // User id is an incrementing value; As of writing this comment, there are somewhere between + // 300-400m users, meaning the userId value for any new accounts would be in that range + // This value is not sensitive in any way, but it is constant. + buffer.p8(userId) + // User hash is an actual hash provided by Jagex, unique for a given account regardless of the world. + // While hash on its own is not useful, there is a potential security concern in how these hashes + // are generated. As such, we take an extra step and salt it with the user id, then hash the + // value once more. Due to the function turning 128 bits of data to 256 bits of data, + // the probability of collisions is extremely thin. + buffer.p8(userHash) + val input = ByteArray(buffer.readableBytes()) + buffer.gdata(input) + // Take the combined byte array and hash it with a SHA-256 hashing function. + // This effectively ensures no one will be able to reverse the original input values, + // while still ensuring we can match multiple play sessions to a single user account. + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(input) + return messageDigest.digest() + } + + public fun writeSuccessfulResponse( + response: LoginResponse.ReconnectOk, + loginBlock: LoginBlock<*>, + previousSession: Session, + ): Session { + // Ensure it isn't null - our decoder pre-validates it long before hitting this function, + // so this exception should never be hit. + val oldSchoolClientType = + checkNotNull(loginBlock.clientType.toOldSchoolClientType()) { + "Login client type cannot be null" + } + val (encodingCipher, decodingCipher) = createStreamCipherPair(loginBlock) + + val encoder = + networkService + .encoderRepositories + .loginMessageEncoderRepository + .getEncoder(response::class.java) + + // Allocate a perfectly-sized buffer for this packet + val bufLength = Byte.SIZE_BYTES + Short.SIZE_BYTES + response.content().readableBytes() + val buffer = ctx.alloc().buffer(bufLength).toJagByteBuf() + buffer.p1(encoder.prot.opcode) + + // Write a placeholder size of 0 bytes + val lengthPos = buffer.writerIndex() + buffer.p2(0) + + // Write the payload + val start = buffer.writerIndex() + encoder.encode(encodingCipher, buffer, response) + val end = buffer.writerIndex() + val written = end - start + + // Update the size with the actual number of bytes written + buffer.writerIndex(lengthPos) + buffer.p2(written) + buffer.writerIndex(end) + + val pipeline = ctx.channel().pipeline() + val oldBlob = previousSession.getBinaryBlobOrNull() + if (oldBlob != null) { + this.ctx.channel().setBinaryBlob(oldBlob) + oldBlob.stream.append( + serverToClient = true, + opcode = 0xFF, + size = Prot.VAR_SHORT, + payload = buffer.buffer.retainedSlice(start, written), + ) + } + val session = + createSession(loginBlock, pipeline, decodingCipher, oldSchoolClientType, encodingCipher) + networkService.js5Authorizer.authorize(ctx.hostAddress()) + ctx.executor().submit { + ctx.write(buffer.buffer) + session.onLoginTransitionComplete() + } + networkLog(logger) { + "Successful game login from channel '${ctx.channel()}': $loginBlock" + } + return session + } + + private fun createStreamCipherPair(loginBlock: LoginBlock<*>): StreamCipherPair { + val encodeSeed = loginBlock.seed + val decodeSeed = + IntArray(encodeSeed.size) { index -> + encodeSeed[index] + DECODE_SEED_OFFSET + } + val provider = networkService.loginHandlers.streamCipherProvider + val encodingCipher = provider.provide(decodeSeed) + val decodingCipher = provider.provide(encodeSeed) + return StreamCipherPair(encodingCipher, decodingCipher) + } + + private fun createSession( + loginBlock: LoginBlock<*>, + pipeline: ChannelPipeline, + decodingCipher: StreamCipher, + oldSchoolClientType: OldSchoolClientType, + encodingCipher: StreamCipher, + ): Session { + val gameMessageConsumerRepository = + networkService + .gameMessageConsumerRepositoryProvider + .provide() + val session = + Session( + networkService.trafficMonitor, + ctx, + networkService + .gameMessageHandlers + .incomingGameMessageQueueProvider + .provide(), + networkService + .gameMessageHandlers + .outgoingGameMessageQueueProvider, + networkService + .gameMessageHandlers + .gameMessageCounterProvider + .provide(), + gameMessageConsumerRepository + .consumers, + gameMessageConsumerRepository + .globalConsumers, + loginBlock, + networkService + .exceptionHandlers + .incomingGameMessageConsumerExceptionHandler, + ) + pipeline.replace( + GameMessageDecoder( + networkService, + session, + decodingCipher, + oldSchoolClientType, + ), + ) + pipeline.replace( + GameMessageEncoder(networkService, encodingCipher, oldSchoolClientType), + ) + pipeline.replace>(GameMessageHandler(networkService, session)) + pipeline.replace( + networkService.idleStateHandlerSuppliers.gameSupplier.supply(), + ) + return session + } + + /** + * Writes a failed response to the client. This is _all_ requests that aren't the ok + * response, even ones where technically it's correct - this is because the client + * always makes a new connection to re-request the login, nothing is kept open + * over long periods of time. + * @param response the response to write to the client - this cannot be the successful + * response or the proof of work response, as those are handled in a special manner. + */ + public fun writeFailedResponse(response: LoginResponse) { + if (response is LoginResponse.ProofOfWork) { + throw IllegalStateException("Proof of Work is handled at the engine level.") + } + if (response is LoginResponse.Successful) { + throw IllegalStateException("Successful login response is handled at the engine level.") + } + if (!ctx.channel().isActive) { + networkLog(logger) { + "Channel '${ctx.channel()}' has gone inactive, skipping failed response." + } + networkService.trafficMonitor.loginChannelTrafficMonitor.addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.GAME_CHANNEL_INACTIVE, + ) + return + } + networkLog(logger) { + "Writing failed login response to channel '${ctx.channel()}': $response" + } + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE) + val disconnectReason = LoginDisconnectionReason.responseToReasonMap[response] + if (disconnectReason != null) { + networkService.trafficMonitor.loginChannelTrafficMonitor.addDisconnectionReason( + ctx.hostAddress(), + disconnectReason, + ) + } + } + + private companion object { + /** + * The offset applied to the decode ISAAC stream cipher seed. + */ + private const val DECODE_SEED_OFFSET: Int = 50 + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginChannelHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginChannelHandler.kt new file mode 100644 index 000000000..a4f903cc0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginChannelHandler.kt @@ -0,0 +1,280 @@ +package net.rsprot.protocol.api.login + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.ChannelFutureListener +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.handler.timeout.IdleStateEvent +import io.netty.handler.timeout.IdleStateHandler +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.js5.Js5ChannelHandler +import net.rsprot.protocol.api.js5.Js5MessageDecoder +import net.rsprot.protocol.api.js5.Js5MessageEncoder +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.channel.replace +import net.rsprot.protocol.common.RSProtConstants +import net.rsprot.protocol.loginprot.incoming.InitGameConnection +import net.rsprot.protocol.loginprot.incoming.InitJs5RemoteConnection +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.IncomingLoginMessage +import java.text.NumberFormat + +/** + * The channel handler for login channels, essentially the very first requests that will + * come in from the client, pointing to either JS5 or the game. + */ +@Suppress("DuplicatedCode") +public class LoginChannelHandler( + public val networkService: NetworkService<*>, +) : SimpleChannelInboundHandler(IncomingLoginMessage::class.java) { + override fun handlerAdded(ctx: ChannelHandlerContext) { + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .incrementConnections(ctx.hostAddress()) + } + + override fun handlerRemoved(ctx: ChannelHandlerContext) { + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .decrementConnections(ctx.hostAddress()) + } + + override fun channelActive(ctx: ChannelHandlerContext) { + ctx.read() + networkLog(logger) { + "Channel is now active: ${ctx.channel()}" + } + ctx.fireChannelActive() + } + + override fun channelRead0( + ctx: ChannelHandlerContext, + msg: IncomingLoginMessage, + ) { + networkLog(logger) { + "Login channel message in channel '${ctx.channel()}': $msg" + } + when (msg) { + InitGameConnection -> { + handleInitGameConnection(ctx) + } + + is InitJs5RemoteConnection -> { + handleInitJs5RemoteConnection(ctx, msg.revision, msg.seed) + } + // TODO: Unknown, SSL web + else -> { + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_UNKNOWN_PACKET, + ) + throw IllegalStateException("Unknown login channel message: $msg") + } + } + } + + private fun handleInitGameConnection(ctx: ChannelHandlerContext) { + val address = ctx.hostAddress() + val count = + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .getCount(address) + val accepted = + networkService + .iNetAddressHandlers + .inetAddressValidator + .acceptGameConnection(address, count) + if (!accepted) { + networkLog(logger) { + "INetAddressValidator rejected game connection for channel ${ctx.channel()}" + } + ctx + .writeAndFlush(LoginResponse.TooManyAttempts) + .addListener(ChannelFutureListener.CLOSE) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_IP_LIMIT, + ) + return + } + val sessionId = + networkService + .loginHandlers + .sessionIdGenerator + .generate(address) + networkLog(logger) { + "Game connection accepted with session id: ${NumberFormat.getNumberInstance().format(sessionId)}" + } + ctx + .writeAndFlush(LoginResponse.Successful(sessionId)) + .addListener( + ChannelFutureListener { future -> + if (!future.isSuccess) { + networkLog(logger) { + "Failed to write a successful game connection response to channel ${ctx.channel()}" + } + future.channel().pipeline().fireExceptionCaught(future.cause()) + future.channel().close() + return@ChannelFutureListener + } + // Extra validation to ensure we don't get any weird scenarios where it's stuck in memory + if (ctx.channel().isActive) { + networkLog(logger) { + "Tracking game INetAddress for channel '${future.channel()}': $address" + } + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .register(address) + } + val pipeline = future.channel().pipeline() + pipeline.replace(LoginConnectionHandler(networkService, sessionId)) + pipeline.replace( + networkService.idleStateHandlerSuppliers.loginSupplier.supply(), + ) + }, + ) + } + + private fun handleInitJs5RemoteConnection( + ctx: ChannelHandlerContext, + revision: Int, + seed: IntArray, + ) { + if (revision != RSProtConstants.REVISION) { + networkLog(logger) { + "Invalid JS5 revision received from channel '${ctx.channel()}': $revision" + } + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_OUT_OF_DATE, + ) + ctx + .writeAndFlush(LoginResponse.ClientOutOfDate) + .addListener(ChannelFutureListener.CLOSE) + return + } + val address = ctx.hostAddress() + val count = + networkService + .iNetAddressHandlers + .js5InetAddressTracker + .getCount(address) + val accepted = + networkService + .iNetAddressHandlers + .inetAddressValidator + .acceptJs5Connection(address, count, seed) + if (!accepted) { + networkLog(logger) { + "INetAddressValidator rejected JS5 connection for channel ${ctx.channel()}" + } + ctx + .writeAndFlush(LoginResponse.IPLimit) + .addListener(ChannelFutureListener.CLOSE) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_IP_LIMIT, + ) + return + } + ctx + .writeAndFlush(LoginResponse.Successful(null)) + .addListener( + ChannelFutureListener { future -> + if (!future.isSuccess) { + networkLog(logger) { + "Failed to write a successful JS5 connection response to channel ${ctx.channel()}" + } + future.channel().pipeline().fireExceptionCaught(future.cause()) + future.channel().close() + return@ChannelFutureListener + } + // Extra validation to ensure we don't get any weird scenarios where it's stuck in memory + if (ctx.channel().isActive) { + networkLog(logger) { + "Tracking JS5 INetAddress for channel '${future.channel()}': $address" + } + networkService + .iNetAddressHandlers + .js5InetAddressTracker + .register(address) + } + val pipeline = ctx.channel().pipeline() + pipeline.replace(Js5MessageDecoder(networkService)) + pipeline.replace(Js5MessageEncoder(networkService)) + pipeline.replace(Js5ChannelHandler(networkService)) + pipeline.replace( + networkService.idleStateHandlerSuppliers.js5Supplier.supply(), + ) + }, + ) + } + + override fun channelReadComplete(ctx: ChannelHandlerContext) { + ctx.flush() + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) { + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, cause) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_EXCEPTION, + ) + val channel = ctx.channel() + if (channel.isOpen) { + channel.close() + } + } + + override fun userEventTriggered( + ctx: ChannelHandlerContext, + evt: Any, + ) { + if (evt is IdleStateEvent) { + networkLog(logger) { + "Login channel has gone idle, closing channel ${ctx.channel()}" + } + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CHANNEL_IDLE, + ) + ctx.close() + } + ctx.fireUserEventTriggered(evt) + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginConnectionHandler.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginConnectionHandler.kt new file mode 100644 index 000000000..977e919df --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginConnectionHandler.kt @@ -0,0 +1,570 @@ +package net.rsprot.protocol.api.login + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.Channel +import io.netty.channel.ChannelFutureListener +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.handler.timeout.IdleStateEvent +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.api.metrics.addDisconnectionReason +import net.rsprot.protocol.binary.BinaryHeader +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.channel.setBinaryHeaderBuilder +import net.rsprot.protocol.common.loginprot.incoming.codec.shared.exceptions.InvalidVersionException +import net.rsprot.protocol.loginprot.incoming.GameLogin +import net.rsprot.protocol.loginprot.incoming.GameReconnect +import net.rsprot.protocol.loginprot.incoming.ProofOfWorkReply +import net.rsprot.protocol.loginprot.incoming.RemainingBetaArchives +import net.rsprot.protocol.loginprot.incoming.pow.ProofOfWork +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.LoginBlockDecodingFunction +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.IncomingLoginMessage +import net.rsprot.protocol.metrics.NetworkTrafficMonitor +import java.text.NumberFormat +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +/** + * The login connection handler, responsible for handling any game connections. + * @property sessionId the session id that was originally generated and written, + * expected to receive the same session id back from the client in the login block. + */ +@Suppress("DuplicatedCode") +public class LoginConnectionHandler( + private val networkService: NetworkService, + private val sessionId: Long, +) : SimpleChannelInboundHandler(IncomingLoginMessage::class.java) { + private var loginState: LoginState = LoginState.UNINITIALIZED + private var loginPacket: IncomingLoginMessage? = null + private var loginHeader: LoginBlock.Header? = null + private lateinit var proofOfWork: ProofOfWork<*, *> + + override fun handlerAdded(ctx: ChannelHandlerContext) { + ctx.read() + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .incrementConnections(ctx.hostAddress()) + } + + override fun handlerRemoved(ctx: ChannelHandlerContext) { + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .decrementConnections(ctx.hostAddress()) + } + + override fun channelActive(ctx: ChannelHandlerContext) { + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .register(ctx.hostAddress()) + networkLog(logger) { + "Channel is now active: ${ctx.channel()}" + } + ctx.fireChannelActive() + } + + override fun channelInactive(ctx: ChannelHandlerContext) { + networkService + .iNetAddressHandlers + .gameInetAddressTracker + .deregister(ctx.hostAddress()) + networkLog(logger) { + "Channel is now inactive: ${ctx.channel()}" + } + ctx.fireChannelInactive() + } + + override fun channelUnregistered(ctx: ChannelHandlerContext) { + // If the channel is unregistered, we must release the login block buffer + releaseLoginBlock() + } + + /** + * Release the login block buffer that was supposed to be decoded after a successful + * proof of work response. + */ + private fun releaseLoginBlock() { + // If login block isn't initialized yet, or has already been decoded, do nothing + val loginPacket = this.loginPacket ?: return + this.loginPacket = null + this.loginHeader = null + val jagBuffer = + when (val packet = loginPacket) { + is GameLogin -> packet.buffer + is GameReconnect -> packet.buffer + else -> return + } + val buffer = jagBuffer.buffer + val refCnt = buffer.refCnt() + if (refCnt > 0) { + buffer.release(refCnt) + } + } + + override fun channelRead0( + ctx: ChannelHandlerContext, + msg: IncomingLoginMessage, + ) { + networkLog(logger) { + "Login connection message in channel '${ctx.channel()}': $msg" + } + when (msg) { + is RemainingBetaArchives -> { + if (this.loginState != LoginState.AWAITING_BETA_RESPONSE) { + ctx.close() + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_INVALID_STEP_AWAITING_BETA_RESPONSE, + ) + return + } + decodeLoginPacket(ctx, msg) + } + + is GameLogin -> { + if (this.loginState != LoginState.UNINITIALIZED) { + ctx.close() + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_INVALID_STEP_UNINITIALIZED, + ) + return + } + this.loginPacket = msg + this.loginHeader = + networkService + .loginHandlers + .loginDecoderService + .decodeHeader(msg.buffer, msg.decoder) + requestProofOfWork(ctx) + } + + is GameReconnect -> { + this.loginPacket = msg + this.loginHeader = + networkService + .loginHandlers + .loginDecoderService + .decodeHeader(msg.buffer, msg.decoder) + continueLogin(ctx) + } + + is ProofOfWorkReply -> { + if (loginState != LoginState.REQUESTED_PROOF_OF_WORK) { + ctx.close() + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_INVALID_STEP_REQUESTED_PROOF_OF_WORK, + ) + return + } + val pow = this.proofOfWork + verifyProofOfWork(pow, msg.result).handle { success, exception -> + try { + if (success != true) { + networkLog(logger) { + "Incorrect proof of work response received from " + + "channel '${ctx.channel()}': ${msg.result}, challenge was: $pow" + } + ctx.writeAndFlush(LoginResponse.LoginFail1).addListener(ChannelFutureListener.CLOSE) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_PROOF_OF_WORK_FAILED, + ) + return@handle + } + if (exception != null) { + logger.error(exception) { + "Exception during proof of work verification " + + "from channel '${ctx.channel()}': $exception" + } + ctx.writeAndFlush(LoginResponse.LoginFail1).addListener(ChannelFutureListener.CLOSE) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_PROOF_OF_WORK_EXCEPTION, + ) + } + networkLog(logger) { + "Correct proof of work response received from channel '${ctx.channel()}': ${msg.result}" + } + continueLogin(ctx) + } catch (e: Exception) { + logger.error(e) { + "Error in handling processed proof of work." + } + } catch (t: Throwable) { + logger.error(t) { + "Fatal error in handling processed proof of work." + } + throw t + } + } + } + + else -> { + throw IllegalStateException("Unknown login connection handler") + } + } + } + + private fun requestProofOfWork(ctx: ChannelHandlerContext) { + val pow = + networkService + .loginHandlers + .proofOfWorkProvider + .provide(ctx.hostAddress(), checkNotNull(this.loginHeader)) + ?: return continueLogin(ctx) + loginState = LoginState.REQUESTED_PROOF_OF_WORK + this.proofOfWork = pow + ctx.writeAndFlush(LoginResponse.ProofOfWork(pow)).addListener( + ChannelFutureListener { future -> + if (!future.isSuccess) { + networkLog(logger) { + "Failed to write a successful proof of work request to channel ${ctx.channel()}" + } + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_PROOF_OF_WORK_EXCEPTION, + ) + future.channel().pipeline().fireExceptionCaught(future.cause()) + future.channel().close() + return@ChannelFutureListener + } + ctx.read() + }, + ) + } + + private fun continueLogin(ctx: ChannelHandlerContext) { + if (networkService.betaWorld) { + loginState = LoginState.AWAITING_BETA_RESPONSE + // Instantly request the remaining beta archives, as that feature + // is implemented incorrectly and serves no functional purpose + ctx + .writeAndFlush(ctx.alloc().buffer(1).writeByte(2)) + .addListener( + ChannelFutureListener { future -> + if (!future.isSuccess) { + networkLog(logger) { + "Failed to write beta crc request to channel ${ctx.channel()}" + } + future.channel().pipeline().fireExceptionCaught(future.cause()) + future.channel().close() + return@ChannelFutureListener + } + ctx.read() + }, + ) + } else { + decodeLoginPacket(ctx, null) + } + } + + override fun channelReadComplete(ctx: ChannelHandlerContext) { + ctx.flush() + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun exceptionCaught( + ctx: ChannelHandlerContext, + cause: Throwable, + ) { + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, cause) + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_EXCEPTION, + ) + val channel = ctx.channel() + if (channel.isOpen) { + channel.close() + } + } + + override fun userEventTriggered( + ctx: ChannelHandlerContext, + evt: Any, + ) { + if (evt is IdleStateEvent) { + networkLog(logger) { + "Login connection has gone idle, closing channel ${ctx.channel()}" + } + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.CONNECTION_IDLE, + ) + ctx.close() + } + } + + private fun decodeLoginPacket( + ctx: ChannelHandlerContext, + remainingBetaArchives: RemainingBetaArchives?, + ) { + val loginPacket = this.loginPacket ?: return + this.loginPacket = null + val responseHandler = GameLoginResponseHandler(networkService, ctx) + when (val packet = loginPacket) { + is GameLogin -> { + decodeGameLoginBuffer(packet, ctx, remainingBetaArchives, responseHandler) + } + + is GameReconnect -> { + decodeGameReconnectBuffer(packet, ctx, remainingBetaArchives, responseHandler) + } + + else -> { + throw IllegalStateException("Unknown login packet: $packet") + } + } + } + + private fun decodeGameLoginBuffer( + packet: GameLogin, + ctx: ChannelHandlerContext, + remainingBetaArchives: RemainingBetaArchives?, + responseHandler: GameLoginResponseHandler, + ) { + decodeLogin( + packet.buffer, + networkService.betaWorld, + packet.decoder, + ).handle { block, exception -> + try { + if (block == null || exception != null) { + if (exception is CompletionException && exception.cause == InvalidVersionException) { + // Write a message indicating client is outdated + ctx + .writeAndFlush(LoginResponse.ClientOutOfDate) + .addListener(ChannelFutureListener.CLOSE) + + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.GAME_CLIENT_OUT_OF_DATE, + ) + return@handle + } + networkService + .exceptionHandlers + .channelExceptionHandler + .exceptionCaught(ctx, exception) + return@handle + } + if (sessionId != block.sessionId) { + networkLog(logger) { + "Mismatching game login session id received from channel " + + "'${ctx.channel()}': ${NumberFormat.getNumberInstance().format(block.sessionId)}, " + + "expected value: ${NumberFormat.getNumberInstance().format(sessionId)}" + } + ctx + .writeAndFlush(LoginResponse.InvalidLoginPacket) + .addListener(ChannelFutureListener.CLOSE) + return@handle + } + if (remainingBetaArchives != null) { + block.mergeBetaCrcs(remainingBetaArchives) + } + networkLog(logger) { + "Successful game login from channel '${ctx.channel()}': $block" + } + setupBinaryHeader(ctx.channel(), block) + val executor = networkService.loginHandlers.loginFlowExecutor + if (executor != null) { + executor.submit { + try { + networkService.gameConnectionHandler.onLogin(responseHandler, block) + } catch (t: Throwable) { + exceptionCaught(ctx, t) + } + } + } else { + networkService.gameConnectionHandler.onLogin(responseHandler, block) + } + @Suppress("UNCHECKED_CAST") + val trafficHandler = networkService.trafficMonitor as NetworkTrafficMonitor> + trafficHandler.addLoginBlock(ctx.hostAddress(), block) + } catch (e: Exception) { + logger.error(e) { + "Error in handling decoded login block." + } + } catch (t: Throwable) { + logger.error(t) { + "Fatal error in handling decoded login block." + } + throw t + } + } + } + + private fun setupBinaryHeader( + channel: Channel, + loginBlock: LoginBlock<*>, + ) { + if (networkService.binaryHeaderProvider == null) { + return + } + val builder = BinaryHeader.Builder() + channel.setBinaryHeaderBuilder(builder) + builder.revision(loginBlock.version) + builder.subRevision(loginBlock.subVersion) + builder.clientType(loginBlock.clientType.id) + builder.platformType(loginBlock.platformType.id) + } + + private fun decodeGameReconnectBuffer( + packet: GameReconnect, + ctx: ChannelHandlerContext, + remainingBetaArchives: RemainingBetaArchives?, + responseHandler: GameLoginResponseHandler, + ) { + decodeLogin( + packet.buffer, + networkService.betaWorld, + packet.decoder, + ).handle { block, exception -> + try { + if (block == null || exception != null) { + if (exception is CompletionException && exception.cause == InvalidVersionException) { + // Write a message indicating client is outdated + ctx + .writeAndFlush(LoginResponse.ClientOutOfDate) + .addListener(ChannelFutureListener.CLOSE) + + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .addDisconnectionReason( + ctx.hostAddress(), + LoginDisconnectionReason.GAME_CLIENT_OUT_OF_DATE, + ) + return@handle + } + logger.error(exception) { + "Failed to decode game reconnect block for channel ${ctx.channel()}" + } + ctx + .writeAndFlush(LoginResponse.LoginFail2) + .addListener(ChannelFutureListener.CLOSE) + return@handle + } + if (sessionId != block.sessionId) { + networkLog(logger) { + "Mismatching reconnect session id received from channel " + + "'${ctx.channel()}': ${NumberFormat.getNumberInstance().format(block.sessionId)}, " + + "expected value: ${NumberFormat.getNumberInstance().format(sessionId)}" + } + ctx + .writeAndFlush(LoginResponse.InvalidLoginPacket) + .addListener(ChannelFutureListener.CLOSE) + return@handle + } + if (remainingBetaArchives != null) { + block.mergeBetaCrcs(remainingBetaArchives) + } + networkLog(logger) { + "Successful game reconnection from channel '${ctx.channel()}': $block" + } + setupBinaryHeader(ctx.channel(), block) + val executor = networkService.loginHandlers.loginFlowExecutor + if (executor != null) { + executor.submit { + try { + networkService.gameConnectionHandler.onReconnect(responseHandler, block) + } catch (t: Throwable) { + exceptionCaught(ctx, t) + } + } + } else { + networkService.gameConnectionHandler.onReconnect(responseHandler, block) + } + @Suppress("UNCHECKED_CAST") + val trafficHandler = networkService.trafficMonitor as NetworkTrafficMonitor> + trafficHandler.addLoginBlock(ctx.hostAddress(), block) + } catch (e: Exception) { + logger.error(e) { + "Error in handling decoded login block." + } + } catch (t: Throwable) { + logger.error(t) { + "Fatal error in handling decoded login block." + } + throw t + } + } + } + + private fun decodeLogin( + buf: JagByteBuf, + betaWorld: Boolean, + function: LoginBlockDecodingFunction, + ): CompletableFuture> = + networkService + .loginHandlers + .loginDecoderService + .decode( + buf, + betaWorld, + this.loginHeader ?: error("Login header not set"), + function, + ) + + private fun , MetaData : ChallengeMetaData> verifyProofOfWork( + pow: ProofOfWork, + result: Long, + ): CompletableFuture = + networkService + .loginHandlers + .proofOfWorkChallengeWorker + .verify( + result, + pow.challengeType, + pow.challengeVerifier, + ) + + private enum class LoginState { + UNINITIALIZED, + REQUESTED_PROOF_OF_WORK, + AWAITING_BETA_RESPONSE, + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginDisconnectionReason.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginDisconnectionReason.kt new file mode 100644 index 000000000..4bcc895f2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginDisconnectionReason.kt @@ -0,0 +1,145 @@ +package net.rsprot.protocol.api.login + +import net.rsprot.protocol.loginprot.outgoing.LoginResponse + +public enum class LoginDisconnectionReason { + CHANNEL_EXCEPTION, + CHANNEL_IDLE, + CHANNEL_OUT_OF_DATE, + CHANNEL_IP_LIMIT, + CHANNEL_WRITE_FAILED, + CHANNEL_UNKNOWN_PACKET, + + CONNECTION_EXCEPTION, + CONNECTION_IDLE, + CONNECTION_UNKNOWN_PACKET, + CONNECTION_INVALID_STEP_AWAITING_BETA_RESPONSE, + CONNECTION_INVALID_STEP_UNINITIALIZED, + CONNECTION_INVALID_STEP_REQUESTED_PROOF_OF_WORK, + CONNECTION_PROOF_OF_WORK_FAILED, + CONNECTION_PROOF_OF_WORK_EXCEPTION, + CONNECTION_GAME_LOGIN_DECODING_FAILED, + CONNECTION_GAME_RECONNECT_FAILED, + CONNECTION_SESSION_ID_MISMATCH, + + GAME_CHANNEL_INACTIVE, + + // The entries below are 1:1 linked to LoginServerProt + GAME_INVALID_USERNAME_OR_PASSWORD, + GAME_BANNED, + GAME_DUPLICATE, + GAME_CLIENT_OUT_OF_DATE, + GAME_SERVER_FULL, + GAME_LOGINSERVER_OFFLINE, + GAME_IP_LIMIT, + GAME_BAD_SESSION_ID, + GAME_FORCE_PASSWORD_CHANGE, + GAME_NEED_MEMBERS_ACCOUNT, + GAME_INVALID_SAVE, + GAME_UPDATE_IN_PROGRESS, + GAME_RECONNECT_OK, + GAME_TOO_MANY_ATTEMPTS, + GAME_IN_MEMBERS_AREA, + GAME_LOCKED, + GAME_CLOSED_BETA_INVITED_ONLY, + GAME_INVALID_LOGINSERVER, + GAME_HOP_BLOCKED, + GAME_INVALID_LOGIN_PACKET, + GAME_LOGINSERVER_NO_REPLY, + GAME_LOGINSERVER_LOAD_ERROR, + GAME_UNKNOWN_REPLY_FROM_LOGINSERVER, + GAME_IP_BLOCKED, + GAME_SERVICE_UNAVAILABLE, + GAME_DISALLOWED_BY_SCRIPT, + GAME_DISPLAYNAME_REQUIRED, + GAME_NEGATIVE_CREDIT, + GAME_INVALID_SINGLE_SIGNON, + GAME_NO_REPLY_FROM_SINGLE_SIGNON, + GAME_PROFILE_BEING_EDITED, + GAME_NO_BETA_ACCESS, + GAME_INSTANCE_INVALID, + GAME_INSTANCE_NOT_SPECIFIED, + GAME_INSTANCE_FULL, + GAME_IN_QUEUE, + GAME_ALREADY_IN_QUEUE, + GAME_BILLING_TIMEOUT, + GAME_NOT_AGREED_TO_NDA, + GAME_EMAIL_NOT_VALIDATED, + GAME_CONNECT_FAIL, + GAME_PRIVACY_POLICY, + GAME_AUTHENTICATOR, + GAME_INVALID_AUTHENTICATOR_CODE, + GAME_UPDATE_DOB, + GAME_TIMEOUT, + GAME_KICK, + GAME_RETRY, + GAME_LOGIN_FAIL_1, + GAME_LOGIN_FAIL_2, + GAME_OUT_OF_DATE_RELOAD, + GAME_PROOF_OF_WORK, + GAME_DOB_ERROR, + GAME_WEBSITE_DOB, + GAME_DOB_REVIEW, + GAME_CLOSED_BETA, + ; + + internal companion object { + internal val responseToReasonMap: Map = buildResponseToReasonMap() + + private fun buildResponseToReasonMap(): Map = + buildMap { + put(LoginResponse.InvalidUsernameOrPassword, GAME_INVALID_USERNAME_OR_PASSWORD) + put(LoginResponse.Banned, GAME_BANNED) + put(LoginResponse.Duplicate, GAME_DUPLICATE) + put(LoginResponse.ClientOutOfDate, GAME_CLIENT_OUT_OF_DATE) + put(LoginResponse.ServerFull, GAME_SERVER_FULL) + put(LoginResponse.LoginServerOffline, GAME_LOGINSERVER_OFFLINE) + put(LoginResponse.IPLimit, GAME_IP_LIMIT) + put(LoginResponse.BadSessionId, GAME_BAD_SESSION_ID) + put(LoginResponse.ForcePasswordChange, GAME_FORCE_PASSWORD_CHANGE) + put(LoginResponse.NeedMembersAccount, GAME_NEED_MEMBERS_ACCOUNT) + put(LoginResponse.InvalidSave, GAME_INVALID_SAVE) + put(LoginResponse.UpdateInProgress, GAME_UPDATE_IN_PROGRESS) + put(LoginResponse.TooManyAttempts, GAME_TOO_MANY_ATTEMPTS) + put(LoginResponse.InMembersArea, GAME_IN_MEMBERS_AREA) + put(LoginResponse.Locked, GAME_LOCKED) + put(LoginResponse.ClosedBetaInvitedOnly, GAME_CLOSED_BETA_INVITED_ONLY) + put(LoginResponse.InvalidLoginServer, GAME_INVALID_LOGINSERVER) + put(LoginResponse.HopBlocked, GAME_HOP_BLOCKED) + put(LoginResponse.InvalidLoginPacket, GAME_INVALID_LOGIN_PACKET) + put(LoginResponse.LoginServerNoReply, GAME_LOGINSERVER_NO_REPLY) + put(LoginResponse.LoginServerLoadError, GAME_LOGINSERVER_LOAD_ERROR) + put(LoginResponse.UnknownReplyFromLoginServer, GAME_UNKNOWN_REPLY_FROM_LOGINSERVER) + put(LoginResponse.IPBlocked, GAME_IP_BLOCKED) + put(LoginResponse.ServiceUnavailable, GAME_SERVICE_UNAVAILABLE) + put(LoginResponse.DisplayNameRequired, GAME_DISPLAYNAME_REQUIRED) + put(LoginResponse.NegativeCredit, GAME_NEGATIVE_CREDIT) + put(LoginResponse.InvalidSingleSignOn, GAME_INVALID_SINGLE_SIGNON) + put(LoginResponse.NoReplyFromSingleSignOn, GAME_NO_REPLY_FROM_SINGLE_SIGNON) + put(LoginResponse.ProfileBeingEdited, GAME_PROFILE_BEING_EDITED) + put(LoginResponse.NoBetaAccess, GAME_NO_BETA_ACCESS) + put(LoginResponse.InstanceInvalid, GAME_INSTANCE_INVALID) + put(LoginResponse.InstanceNotSpecified, GAME_INSTANCE_NOT_SPECIFIED) + put(LoginResponse.InstanceFull, GAME_INSTANCE_FULL) + put(LoginResponse.InQueue, GAME_IN_QUEUE) + put(LoginResponse.AlreadyInQueue, GAME_ALREADY_IN_QUEUE) + put(LoginResponse.BillingTimeout, GAME_BILLING_TIMEOUT) + put(LoginResponse.NotAgreedToNda, GAME_NOT_AGREED_TO_NDA) + put(LoginResponse.EmailNotValidated, GAME_EMAIL_NOT_VALIDATED) + put(LoginResponse.ConnectFail, GAME_CONNECT_FAIL) + put(LoginResponse.PrivacyPolicy, GAME_PRIVACY_POLICY) + put(LoginResponse.Authenticator, GAME_AUTHENTICATOR) + put(LoginResponse.InvalidAuthenticatorCode, GAME_INVALID_AUTHENTICATOR_CODE) + put(LoginResponse.UpdateDob, GAME_UPDATE_DOB) + put(LoginResponse.Timeout, GAME_TIMEOUT) + put(LoginResponse.Kick, GAME_KICK) + put(LoginResponse.Retry, GAME_RETRY) + put(LoginResponse.LoginFail1, GAME_LOGIN_FAIL_1) + put(LoginResponse.LoginFail2, GAME_LOGIN_FAIL_2) + put(LoginResponse.OutOfDateReload, GAME_OUT_OF_DATE_RELOAD) + put(LoginResponse.DobError, GAME_DOB_ERROR) + put(LoginResponse.DobReview, GAME_DOB_REVIEW) + put(LoginResponse.ClosedBeta, GAME_CLOSED_BETA) + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageDecoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageDecoder.kt new file mode 100644 index 000000000..6f06b083d --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageDecoder.kt @@ -0,0 +1,124 @@ +package net.rsprot.protocol.api.login + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.ByteToMessageDecoder +import io.netty.handler.codec.DecoderException +import net.rsprot.buffer.extensions.g1 +import net.rsprot.buffer.extensions.g2 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.Prot +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.decoder.DecoderState +import net.rsprot.protocol.api.logging.networkLog +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository + +/** + * The decoder for any login messages. + */ +@Suppress("DuplicatedCode") +public class LoginMessageDecoder( + public val networkService: NetworkService<*>, +) : ByteToMessageDecoder() { + private val decoders: MessageDecoderRepository = + networkService + .decoderRepositories + .loginMessageDecoderRepository + + private var state: DecoderState = DecoderState.READ_OPCODE + private lateinit var decoder: MessageDecoder<*> + private var opcode: Int = -1 + private var length: Int = 0 + + override fun decode( + ctx: ChannelHandlerContext, + input: ByteBuf, + out: MutableList, + ) { + if (state == DecoderState.READ_OPCODE) { + if (!input.isReadable) { + return + } + if (networkService.loginHandlers.suppressInvalidLoginProts) { + this.opcode = input.g1() and 0xFF + val decoder = decoders.getDecoderOrNull(opcode) + if (decoder == null) { + networkLog(logger) { + "Invalid login packet from channel ${ctx.channel()}': ${this.opcode}" + } + ctx.close() + return + } + this.decoder = decoder + this.length = this.decoder.prot.size + state = + if (this.length >= 0) { + DecoderState.READ_PAYLOAD + } else { + DecoderState.READ_LENGTH + } + } else { + this.opcode = input.g1() and 0xFF + this.decoder = decoders.getDecoder(opcode) + this.length = this.decoder.prot.size + state = + if (this.length >= 0) { + DecoderState.READ_PAYLOAD + } else { + DecoderState.READ_LENGTH + } + } + } + + if (state == DecoderState.READ_LENGTH) { + when (length) { + Prot.VAR_BYTE -> { + if (!input.isReadable(Byte.SIZE_BYTES)) { + return + } + this.length = input.g1() + } + + Prot.VAR_SHORT -> { + if (!input.isReadable(Short.SIZE_BYTES)) { + return + } + this.length = input.g2() + } + + else -> { + throw IllegalStateException("Invalid length: $length for opcode $opcode") + } + } + state = DecoderState.READ_PAYLOAD + } + + if (state == DecoderState.READ_PAYLOAD) { + if (!input.isReadable(length)) { + return + } + val payload = input.readSlice(length) + out += decoder.decode(payload.toJagByteBuf()) + if (payload.isReadable) { + throw DecoderException( + "Decoder ${decoder.javaClass} did not read entire payload " + + "of opcode $opcode: ${payload.readableBytes()}", + ) + } + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .incrementIncomingPackets(ctx.hostAddress(), opcode, length) + + state = DecoderState.READ_OPCODE + } + } + + private companion object { + private val logger: InlineLogger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageEncoder.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageEncoder.kt new file mode 100644 index 000000000..097a033aa --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/login/LoginMessageEncoder.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.api.login + +import io.netty.channel.ChannelHandlerContext +import net.rsprot.crypto.cipher.NopStreamCipher +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.api.NetworkService +import net.rsprot.protocol.api.encoder.OutgoingMessageEncoder +import net.rsprot.protocol.api.handlers.OutgoingMessageSizeEstimator +import net.rsprot.protocol.channel.hostAddress +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository + +/** + * The encoder for any login messages. + */ +public class LoginMessageEncoder( + public val networkService: NetworkService<*>, +) : OutgoingMessageEncoder() { + override val cipher: StreamCipher = NopStreamCipher + override val repository: MessageEncoderRepository<*> = + networkService.encoderRepositories.loginMessageEncoderRepository + override val validate: Boolean = false + override val estimator: OutgoingMessageSizeEstimator = networkService.messageSizeEstimator + + override fun onMessageWritten( + ctx: ChannelHandlerContext, + opcode: Int, + payloadSize: Int, + ) { + networkService + .trafficMonitor + .loginChannelTrafficMonitor + .incrementOutgoingPackets(ctx.hostAddress(), opcode, payloadSize) + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/metrics/ChannelTrafficHandlerExtensions.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/metrics/ChannelTrafficHandlerExtensions.kt new file mode 100644 index 000000000..5379ff11c --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/metrics/ChannelTrafficHandlerExtensions.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.api.metrics + +import net.rsprot.protocol.api.game.GameDisconnectionReason +import net.rsprot.protocol.api.js5.Js5DisconnectionReason +import net.rsprot.protocol.api.login.LoginDisconnectionReason +import net.rsprot.protocol.metrics.channel.impl.GameChannelTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.Js5ChannelTrafficMonitor +import net.rsprot.protocol.metrics.channel.impl.LoginChannelTrafficMonitor + +internal fun LoginChannelTrafficMonitor.addDisconnectionReason( + hostAddress: String, + reason: LoginDisconnectionReason, +) { + addDisconnectionReason( + hostAddress, + reason.ordinal, + ) +} + +internal fun Js5ChannelTrafficMonitor.addDisconnectionReason( + hostAddress: String, + reason: Js5DisconnectionReason, +) { + addDisconnectionReason( + hostAddress, + reason.ordinal, + ) +} + +internal fun GameChannelTrafficMonitor.addDisconnectionReason( + hostAddress: String, + reason: GameDisconnectionReason, +) { + addDisconnectionReason( + hostAddress, + reason.ordinal, + ) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/obfuscation/OpcodeMapper.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/obfuscation/OpcodeMapper.kt new file mode 100644 index 000000000..c589fef01 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/obfuscation/OpcodeMapper.kt @@ -0,0 +1,175 @@ +package net.rsprot.protocol.api.obfuscation + +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import java.util.SplittableRandom + +/** + * An opcode mapper implementation, allowing for simple obfuscation of opcodes. + * @property sourceOpcodes the original input opcodes (ones found in client by default) + * @property obfuscatedOpcodes the modified opcodes for obfuscation + */ +@OptIn(ExperimentalUnsignedTypes::class) +public class OpcodeMapper private constructor( + @PublishedApi + internal val sourceOpcodes: UByteArray, + @PublishedApi + internal val obfuscatedOpcodes: UByteArray, +) { + @Suppress("NOTHING_TO_INLINE") + public inline fun encode(opcode: Int): Int { + return sourceOpcodes[opcode].toInt() + } + + @Suppress("NOTHING_TO_INLINE") + public inline fun decode(opcode: Int): Int { + return obfuscatedOpcodes[opcode].toInt() + } + + /** + * Builds a source -> obfuscated hashmap of the opcodes. + */ + public fun toMap(): Map { + val source = sourceOpcodes.map { it.toInt() } + val obfuscated = obfuscatedOpcodes.map { it.toInt() } + return (source zip obfuscated).toMap() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpcodeMapper + + if (!sourceOpcodes.contentEquals(other.sourceOpcodes)) return false + if (!obfuscatedOpcodes.contentEquals(other.obfuscatedOpcodes)) return false + + return true + } + + override fun hashCode(): Int { + var result = sourceOpcodes.contentHashCode() + result = 31 * result + obfuscatedOpcodes.contentHashCode() + return result + } + + override fun toString(): String { + return "OpcodeMapper(" + + "sourceOpcodes=${sourceOpcodes.contentToString()}, " + + "obfuscatedOpcodes=${obfuscatedOpcodes.contentToString()}" + + ")" + } + + public companion object { + /** + * Builds an opcode mapper of input and output opcodes, with no modifications applied. + * @param inputOpcodes the real opcodes used + * @param outputOpcodes the obfuscated opcodes used + * @return an instance of an opcode mapper. + */ + @JvmStatic + public fun of( + inputOpcodes: ByteArray, + outputOpcodes: ByteArray, + ): OpcodeMapper { + return OpcodeMapper( + inputOpcodes.toUByteArray(), + outputOpcodes.toUByteArray(), + ) + } + + /** + * Builds an opcode mapper for server-to-client packets out of the provided [seed]. + */ + @JvmStatic + public fun seededServerProtMapper(seed: Long): OpcodeMapper { + val opcodes = + GameServerProt.entries + .filter { it.opcode >= 0 } + .map { it.opcode } + .toIntArray() + return fromSeed(seed, opcodes) + } + + /** + * Builds an opcode mapper for client-to-server packets out of the provided [seed]. + */ + @JvmStatic + public fun seededClientProtMapper(seed: Long): OpcodeMapper { + val opcodes = + GameClientProt.entries + .filter { it.opcode >= 0 } + .map { it.opcode } + .toIntArray() + return fromSeed(seed, opcodes) + } + + /** + * Builds an opcode mapper from a specific seed, for an int array of opcodes. + * @param seed the input seed to use. The same seed will always generate the same + * values for a given array of [inputs]. + * @param inputs the source opcodes to remap. + * @throws IllegalArgumentException if inputs don't meet our requirements + * (consecutive values of 0..n with no gaps) + * @return an instance of an opcode mapper. + */ + @JvmStatic + public fun fromSeed( + seed: Long, + inputs: IntArray, + ): OpcodeMapper { + validate(inputs) + + val count = inputs.size + // Fisher–Yates permutation + val permutated = IntArray(count) { it } + val random = SplittableRandom(seed) + for (index in count - 1 downTo 1) { + val obfuscated = random.nextInt(index + 1) + val original = permutated[index] + permutated[index] = permutated[obfuscated] + permutated[obfuscated] = original + } + + // Compress the data to an unsigned byte array for better memory footprint + // which also makes it more likely to get inlined by JIT. + val enc = UByteArray(count) + val dec = UByteArray(count) + for (x in 0 until count) { + val y = permutated[x] + enc[x] = y.toUByte() + dec[y] = x.toUByte() + } + + return OpcodeMapper(enc, dec) + } + + /** + * Validates the [inputs] opcodes array to ensure it has consecutive values from 0..n, + * and has no duplicates. + * @param inputs the inputs to validate + * @throws IllegalArgumentException if inputs don't meet our requirements. + */ + private fun validate(inputs: IntArray) { + val count = inputs.size + require(count in 1..256) { + "Inputs size must be 1..256" + } + val seen = BooleanArray(count) + var max = -1 + for (opcode in inputs) { + require(opcode in 0 until count) { + "Inputs must be 0..${count - 1} with no gaps; found $opcode" + } + require(!seen[opcode]) { + "Duplicate input opcode: $opcode" + } + seen[opcode] = true + if (opcode > max) { + max = opcode + } + } + require(max == count - 1) { "inputs must cover 0..${count - 1} exactly" } + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageDecoderRepositories.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageDecoderRepositories.kt new file mode 100644 index 000000000..700ce440e --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageDecoderRepositories.kt @@ -0,0 +1,60 @@ +package net.rsprot.protocol.api.repositories + +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.rsa.RsaKeyPair +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.client.OldSchoolClientType.DESKTOP +import net.rsprot.protocol.common.js5.incoming.prot.Js5MessageDecoderRepository +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginMessageDecoderRepository +import net.rsprot.protocol.game.incoming.prot.DesktopGameMessageDecoderRepository +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository +import java.math.BigInteger + +/** + * The message decoder repositories for login, JS5 and game, all held in the same place. + */ +@OptIn(ExperimentalStdlibApi::class) +public class MessageDecoderRepositories( + public val loginMessageDecoderRepository: MessageDecoderRepository, + public val js5MessageDecoderRepository: MessageDecoderRepository, + public val gameMessageDecoderRepositories: ClientTypeMap>, +) { + public constructor( + clientTypes: List, + exp: BigInteger, + mod: BigInteger, + gameMessageDecoderRepositories: ClientTypeMap>, + ) : this( + LoginMessageDecoderRepository.build(clientTypes, exp, mod), + Js5MessageDecoderRepository.build(), + gameMessageDecoderRepositories, + ) + + public companion object { + public fun initialize( + clientTypes: List, + rsaKeyPair: RsaKeyPair, + huffmanCodecProvider: HuffmanCodecProvider, + ): MessageDecoderRepositories { + val repositories = + buildList { + if (DESKTOP in clientTypes) { + add(DESKTOP to DesktopGameMessageDecoderRepository.build(huffmanCodecProvider)) + } + } + val clientTypeMap = + ClientTypeMap.of( + OldSchoolClientType.COUNT, + repositories, + ) + return MessageDecoderRepositories( + clientTypes, + rsaKeyPair.exponent, + rsaKeyPair.modulus, + clientTypeMap, + ) + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageEncoderRepositories.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageEncoderRepositories.kt new file mode 100644 index 000000000..d4b5ff15f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/repositories/MessageEncoderRepositories.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.api.repositories + +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.js5.outgoing.prot.Js5MessageEncoderRepository +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginMessageEncoderRepository +import net.rsprot.protocol.game.outgoing.prot.DesktopGameMessageEncoderRepository +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository + +/** + * The message encoder repository for all outgoing messages, for JS5, login and game. + */ +@OptIn(ExperimentalStdlibApi::class) +public class MessageEncoderRepositories( + public val loginMessageEncoderRepository: MessageEncoderRepository, + public val js5MessageEncoderRepository: MessageEncoderRepository, + public val gameMessageEncoderRepositories: ClientTypeMap>, +) { + public constructor( + huffmanCodecProvider: HuffmanCodecProvider, + ) : this( + LoginMessageEncoderRepository.build(), + Js5MessageEncoderRepository.build(), + ClientTypeMap.of( + OldSchoolClientType.COUNT, + listOf(OldSchoolClientType.DESKTOP to DesktopGameMessageEncoderRepository.build(huffmanCodecProvider)), + ), + ) +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/NpcInfoSupplier.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/NpcInfoSupplier.kt new file mode 100644 index 000000000..19ba147c6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/NpcInfoSupplier.kt @@ -0,0 +1,62 @@ +package net.rsprot.protocol.api.suppliers + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.game.outgoing.info.filter.DefaultExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarExceptionHandler +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker + +/** + * The supplier for NPC info protocol, allowing the construction of the protocol and its + * correct use. + * @property npcAvatarExceptionHandler the exception handler for NPC avatars, + * catching any exceptions that happen during pre-computations of NPC avatar blocks. + * @property npcExtendedInfoFilter the filter for NPC extended info blocks, responsible + * for ensuring that the NPC info packet never exceeds the 40 kilobyte limit. + * @property npcInfoProtocolWorker the worker behind the NPC info protocol, responsible + * for executing the underlying tasks, either on a single thread or a thread pool. + */ +public class NpcInfoSupplier + @JvmOverloads + public constructor( + public val npcAvatarExceptionHandler: NpcAvatarExceptionHandler = + NpcAvatarExceptionHandler { index, exception -> + logger.error(exception) { + "Exception in processing npc avatar for npc $index" + } + }, + public val npcExtendedInfoFilter: ExtendedInfoFilter = DefaultExtendedInfoFilter(), + public val npcInfoProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcInfoSupplier + + if (npcExtendedInfoFilter != other.npcExtendedInfoFilter) return false + if (npcInfoProtocolWorker != other.npcInfoProtocolWorker) return false + if (npcAvatarExceptionHandler != other.npcAvatarExceptionHandler) return false + + return true + } + + override fun hashCode(): Int { + var result = npcExtendedInfoFilter.hashCode() + result = 31 * result + npcInfoProtocolWorker.hashCode() + result = 31 * result + npcAvatarExceptionHandler.hashCode() + return result + } + + override fun toString(): String = + "NpcInfoSupplier(" + + "npcAvatarExceptionHandler=$npcAvatarExceptionHandler, " + + "npcExtendedInfoFilter=$npcExtendedInfoFilter, " + + "npcInfoProtocolWorker=$npcInfoProtocolWorker" + + ")" + + private companion object { + private val logger = InlineLogger() + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/PlayerInfoSupplier.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/PlayerInfoSupplier.kt new file mode 100644 index 000000000..570f780e5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/PlayerInfoSupplier.kt @@ -0,0 +1,43 @@ +package net.rsprot.protocol.api.suppliers + +import net.rsprot.protocol.game.outgoing.info.filter.DefaultExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker + +/** + * A supplier for the player info protocol. + * @property playerExtendedInfoFilter the extended info filter responsible for ensuring that + * the player info packet never exceeds the 40 kilobyte limitation. + * @property playerInfoProtocolWorker the worker behind the player info protocol. + */ +public class PlayerInfoSupplier + @JvmOverloads + public constructor( + public val playerExtendedInfoFilter: ExtendedInfoFilter = DefaultExtendedInfoFilter(), + public val playerInfoProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerInfoSupplier + + if (playerExtendedInfoFilter != other.playerExtendedInfoFilter) return false + if (playerInfoProtocolWorker != other.playerInfoProtocolWorker) return false + + return true + } + + override fun hashCode(): Int { + var result = playerExtendedInfoFilter.hashCode() + result = 31 * result + playerInfoProtocolWorker.hashCode() + return result + } + + override fun toString(): String = + "PlayerInfoSupplier(" + + "playerExtendedInfoFilter=$playerExtendedInfoFilter, " + + "playerInfoProtocolWorker=$playerInfoProtocolWorker" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/WorldEntityInfoSupplier.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/WorldEntityInfoSupplier.kt new file mode 100644 index 000000000..d2fa6102f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/suppliers/WorldEntityInfoSupplier.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.api.suppliers + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarExceptionHandler + +/** + * The supplier for world entity info protocol, allowing the construction of the protocol and its + * correct use. + * @property worldEntityInfoProtocolWorker the worker behind the world entity info protocol, responsible + * for executing the underlying tasks, either on a single thread or a thread pool. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class WorldEntityInfoSupplier + @JvmOverloads + public constructor( + public val worldEntityAvatarExceptionHandler: WorldEntityAvatarExceptionHandler = + WorldEntityAvatarExceptionHandler { index, exception -> + logger.error(exception) { + "Exception in world entity avatar processing for index $index" + } + }, + public val worldEntityInfoProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), + ) { + private companion object { + private val logger = InlineLogger() + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/traffic/ConcurrentNetworkTrafficWriter.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/traffic/ConcurrentNetworkTrafficWriter.kt new file mode 100644 index 000000000..16da6eaef --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/traffic/ConcurrentNetworkTrafficWriter.kt @@ -0,0 +1,218 @@ +package net.rsprot.protocol.api.traffic + +import net.rsprot.protocol.loginprot.incoming.util.HostPlatformStats +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.metrics.channel.snapshots.impl.ConcurrentChannelTrafficSnapshot +import net.rsprot.protocol.metrics.channel.snapshots.util.PacketSnapshot +import net.rsprot.protocol.metrics.snapshots.impl.ConcurrentNetworkTrafficSnapshot +import net.rsprot.protocol.metrics.writer.NetworkTrafficWriter +import java.text.NumberFormat +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +public data object ConcurrentNetworkTrafficWriter : NetworkTrafficWriter, String> { + private const val INDENT: String = " " + private val dateTimeFormatter = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.FULL) + .withZone(ZoneId.systemDefault()) + + override fun write(snapshot: ConcurrentNetworkTrafficSnapshot<*>): String = + buildString { + val start = dateTimeFormatter.format(snapshot.startDateTime) + val end = dateTimeFormatter.format(snapshot.endDateTime) + appendLine("Network Traffic Snapshot") + indent().append("Snapshot started on: ").appendLine(start) + indent().append("Snapshot ended on: ").appendLine(end) + indent().append("Elapsed duration: ").appendLine(snapshot.elapsed) + indent() + .append("Connection requests: ") + .appendLine(format(snapshot.connectionRequests)) + appendLine() + appendChannelMetrics( + snapshot.loginSnapshot as ConcurrentChannelTrafficSnapshot<*, *, *>, + "Login", + ) + appendLine() + appendChannelMetrics( + snapshot.js5Snapshot as ConcurrentChannelTrafficSnapshot<*, *, *>, + "JS5", + ) + appendLine() + appendChannelMetrics( + snapshot.gameSnapshot as ConcurrentChannelTrafficSnapshot<*, *, *>, + "Game", + ) + appendLine() + @Suppress("UNCHECKED_CAST") + val loginBlocks = snapshot.loginBlocks as Map>> + appendLoginBlocks(loginBlocks) + } + + private fun StringBuilder.appendLoginBlocks(loginBlocks: Map>>) { + appendLine("Login Blocks") + for ((k, v) in loginBlocks) { + indent().append("Inet Address: ").appendLine(k) + for (block in v) { + indent(2).appendLine("Login Block") + indent(3).append("Version: ").appendLine(block.version) + indent(3).append("Sub Version: ").appendLine(block.subVersion) + indent(3).append("Client Type: ").appendLine(block.clientType) + indent(3).append("Platform Type: ").appendLine(block.platformType) + indent(3).append("External Authenticator: ").appendLine(block.hasExternalAuthenticator) + indent(3).append("Seed: ").appendLine(block.seed.contentToString()) + indent(3).append("Session Id: ").appendLine(format(block.sessionId)) + indent(3).append("Username: ").appendLine(block.username) + indent(3).append("Low Detail: ").appendLine(block.lowDetail) + indent(3).append("Resizable: ").appendLine(block.resizable) + indent(3).append("Width: ").appendLine(block.width) + indent(3).append("Height: ").appendLine(block.height) + indent(3).append("UUID: ").appendLine(block.uuid.contentToString()) + indent(3).append("Site Settings: ").appendLine(block.siteSettings) + indent(3).append("Affiliate: ").appendLine(block.affiliate) + indent(3).append("Deep Links: ").appendLine(block.deepLinks) + indent(3).append("Reflection Check Const: ").appendLine(block.reflectionCheckerConst) + indent(3).append("CRC: ").appendLine(block.crc.toIntArray().contentToString()) + appendHostPlatformStats(block.hostPlatformStats) + } + } + } + + private fun StringBuilder.appendHostPlatformStats(stats: HostPlatformStats) { + indent(3).appendLine("Host Platform Stats") + indent(4).append("Version: ").appendLine(stats.version) + indent(4).append("OS Type: ").appendLine(stats.osType) + indent(4).append("OS 64 Bit: ").appendLine(stats.os64Bit) + indent(4).append("OS Version: ").appendLine(stats.osVersion) + indent(4).append("Java Vendor: ").appendLine(stats.javaVendor) + indent(4) + .append("Java: ") + .append(stats.javaVendor) + .append(" ") + .append(stats.javaVersionMajor) + .append(".") + .append(stats.javaVersionMinor) + .append(".") + .appendLine(stats.javaVersionPatch) + + indent(4).append("Applet: ").appendLine(stats.applet) + indent(4).append("Java Max Memory (MB): ").appendLine(stats.javaMaxMemoryMb) + indent(4).append("Java Available Processors: ").appendLine(stats.javaAvailableProcessors) + indent(4).append("System Memory: ").appendLine(stats.systemMemory) + indent(4).append("System Speed: ").appendLine(stats.systemSpeed) + indent(4).append("GPU DX Name: ").appendLine(stats.gpuDxName) + indent(4).append("GPU GL Name: ").appendLine(stats.gpuGlName) + indent(4).append("GPU GL Version: ").appendLine(stats.gpuGlVersion) + indent(4) + .append("GPU Driver Date: ") + .append(stats.gpuDriverMonth) + .append(".") + .appendLine(stats.gpuDriverYear) + indent(4).append("CPU Manufacturer: ").appendLine(stats.cpuManufacturer) + indent(4).append("CPU Brand: ").appendLine(stats.cpuBrand) + indent(4) + .append("CPU Counts: ") + .append(stats.cpuCount1) + .append(", ") + .appendLine(stats.cpuCount2) + indent(4).append("CPU Features: ").appendLine(stats.cpuFeatures.contentToString()) + indent(4).append("CPU Signature: ").appendLine(stats.cpuSignature) + indent(4).append("Client Name: ").appendLine(stats.clientName) + indent(4).append("Device Name: ").appendLine(stats.deviceName) + } + + private fun StringBuilder.appendChannelMetrics( + snapshot: ConcurrentChannelTrafficSnapshot<*, *, *>, + title: String, + ) { + append(title).appendLine(" Channel Snapshot") + val start = dateTimeFormatter.format(snapshot.startDateTime) + val end = dateTimeFormatter.format(snapshot.endDateTime) + indent().append(title).append(" snapshot started on: ").appendLine(start) + indent().append(title).append(" snapshot ended on: ").appendLine(end) + indent().append(title).append(" elapsed duration: ").appendLine(snapshot.elapsed) + appendInetAddressMetrics(snapshot, title) + } + + private fun StringBuilder.appendInetAddressMetrics( + channelSnapshot: ConcurrentChannelTrafficSnapshot<*, *, *>, + title: String, + ) { + indent().append(title).appendLine(" INet Address Metrics") + val untrackedConnections = + channelSnapshot + .activeConnectionsByAddress + .entries + .filter { + it.key !in channelSnapshot.inetAddressSnapshots + } + for ((address, count) in untrackedConnections) { + indent(2).append("INet Address: ").appendLine(address) + indent(3).append("Active connections: ").appendLine(count) + } + for ((address, ss) in channelSnapshot.inetAddressSnapshots) { + indent(2).append("INet Address: ").appendLine(address) + val activeConnections = channelSnapshot.activeConnectionsByAddress[address] + if (activeConnections != null) { + indent(3).append("Active connections: ").appendLine(activeConnections) + } + appendPacketSnapshots("Incoming", ss.incomingPackets) + appendPacketSnapshots("Outgoing", ss.outgoingPackets) + appendDisconnectionReasons(ss.disconnectionsByReason) + } + } + + private fun StringBuilder.appendPacketSnapshots( + prefix: String, + map: Map<*, PacketSnapshot>, + ) { + val incoming = + map + .filterNot { it.value.count == 0L } + .entries + .sortedWith( + compareBy>( + { it.value.cumulativePayloadSize }, + { it.value.count }, + ).reversed(), + ) + for ((k, v) in incoming) { + indent(3) + .append(prefix) + .append(" packet: ") + .append(k) + .append(", count: ") + .append(format(v.count)) + .append(", payload sum: ") + .append(format(v.cumulativePayloadSize)) + .appendLine(if (v.cumulativePayloadSize == 1L) " byte" else " bytes") + } + } + + private fun StringBuilder.appendDisconnectionReasons(map: Map<*, Int>) { + val incoming = + map + .filterNot { it.value == 0 } + .entries + .sortedByDescending { it.value } + for ((k, v) in incoming) { + indent(3) + .append("Disconnection reason: ") + .append(k) + .append(", count: ") + .appendLine(format(v)) + } + } + + private fun format(number: Int): String = NumberFormat.getIntegerInstance().format(number) + + private fun format(number: Long): String = NumberFormat.getIntegerInstance().format(number) + + private fun StringBuilder.indent(count: Int = 1): StringBuilder = + apply { + repeat(count) { + append(INDENT) + } + } +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/FutureExtensions.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/FutureExtensions.kt new file mode 100644 index 000000000..d01e8d00f --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/FutureExtensions.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.api.util + +import io.netty.util.concurrent.Future +import java.util.concurrent.CompletableFuture + +/** + * Turns a normal Netty future object into a completable future, allowing + * for easier use of it. + */ +public fun Future.asCompletableFuture(): CompletableFuture { + if (isDone) { + return if (isSuccess) { + CompletableFuture.completedFuture(now) + } else { + CompletableFuture.failedFuture(cause()) + } + } + + val future = CompletableFuture() + + addListener { + if (isSuccess) { + future.complete(now) + } else { + future.completeExceptionally(cause()) + } + } + + return future +} diff --git a/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBuffer.kt b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBuffer.kt new file mode 100644 index 000000000..8276f2970 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/main/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBuffer.kt @@ -0,0 +1,213 @@ +package net.rsprot.protocol.api.util + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import io.netty.buffer.PooledByteBufAllocator +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.zone.header.DesktopUpdateZonePartialEnclosedEncoder +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.message.ZoneProt +import net.rsprot.protocol.message.codec.UpdateZonePartialEnclosedCache +import java.util.EnumMap +import java.util.LinkedList + +public class ZonePartialEnclosedCacheBuffer + @JvmOverloads + public constructor( + public val supportedClients: List = OldSchoolClientType.entries, + private val byteBufAllocator: ByteBufAllocator = PooledByteBufAllocator.DEFAULT, + internal var activeCachedBuffers: LinkedList = LinkedList(), + public val zoneCountBeforeLeakWarning: Int = DEFAULT_ZONE_COUNT_BEFORE_LEAK_WARNING, + public val bufRetentionCountBeforeRelease: Int = DEFAULT_BUF_RETENTION_COUNT_BEFORE_RELEASE, + ) { + /** + * Contains lists of buffers from [computeZone] calls that were unable to be released due to their [ByteBuf.refCnt]. + */ + internal val retainedBufferReferences = ArrayDeque>() + + /** + * The amount of [computeZone] calls that have been made before calling [releaseBuffers]. In other words, tracks + * the amount of zones that have been computed in a single tick. (or multiple ticks if the consumer does not + * properly call [releaseBuffers]) + */ + internal var currentZoneComputationCount: Int = 0 + + /** + * Computes the expected [net.rsprot.protocol.game.outgoing.zone.header.UpdateZonePartialEnclosed.payload] for each + * client type in [supportedClients] and returns them in map with the [OldSchoolClientType] as key and payload byte + * buffer as its value. + * + * The [pendingTickProtList] parameter should be a list of zone prot events that occurred during the _current world + * cycle_. It **should not** include prots from previous cycles, such as `LocAddChange` and `ObjAdd` from previously + * spawned locs or objs, respectively. + * + * Zone partial enclosed can include the following, when applicable: + * - [net.rsprot.protocol.game.outgoing.zone.payload.LocAddChangeV2] + * - [net.rsprot.protocol.game.outgoing.zone.payload.LocAnim] + * - [net.rsprot.protocol.game.outgoing.zone.payload.LocDel] + * - [net.rsprot.protocol.game.outgoing.zone.payload.LocMerge] + * - [net.rsprot.protocol.game.outgoing.zone.payload.MapAnim] + * - [net.rsprot.protocol.game.outgoing.zone.payload.ObjAdd]: *Only for "publicly-visible" objs* + * - [net.rsprot.protocol.game.outgoing.zone.payload.ObjDel]: *Only for "publicly-visible" objs* + * - [net.rsprot.protocol.game.outgoing.zone.payload.ObjEnabledOps] + * - [net.rsprot.protocol.game.outgoing.zone.payload.SoundArea] + */ + public fun computeZone( + pendingTickProtList: Collection, + ): EnumMap { + val clientBuffers = buildZoneProtBuffers(pendingTickProtList) + activeCachedBuffers += clientBuffers.values + incrementZoneComputationCount() + return clientBuffers + } + + /** + * Computes the expected zone payload **only** for a single [OldSchoolClientType] and returns the corresponding + * [ByteBuf]. + * + * Similar to [computeZone], but for a single client rather than all [supportedClients]. + */ + public fun computeZoneForClient( + client: OldSchoolClientType, + pendingTickProtList: Collection, + ): ByteBuf { + val encoder = supportedEncoders[client] + + val buffer = encoder.buildCache(byteBufAllocator, pendingTickProtList) + activeCachedBuffers.add(buffer) + incrementZoneComputationCount() + + return buffer + } + + private fun buildZoneProtBuffers( + protList: Collection, + ): EnumMap { + val map = createClientBufferEnumMap() + for (client in supportedClients) { + val encoder = supportedEncoders.getOrNull(client) ?: continue + val buffer = encoder.buildCache(byteBufAllocator, protList) + map[client] = buffer + } + return map + } + + private fun incrementZoneComputationCount() { + currentZoneComputationCount++ + logPossibleLeak() + } + + private fun logPossibleLeak() { + if (currentZoneComputationCount < zoneCountBeforeLeakWarning) { + return + } + logger.warn { "Update zone partial enclosed buffers have not been correctly released!" } + } + + /** + * Releases all prebuilt zone partial enclosed buffers that no longer have active references, indicating that all + * Netty channels have finished writing these buffers. This method also handles buffers that could not be + * immediately released due to their reference count ([ByteBuf.refCnt]). + * + * Under typical conditions, the encoder should trigger the buffer release within a single cycle. However, if a + * buffer remains unreleased due to a session closing or other interruptions, this method ensures they are + * handled correctly. Implementation details on this mechanism can be seen in [releaseBuffersOnThreshold]. + * + * **Usage Note:** This function should be invoked **once** at the end of **every tick** to ensure proper buffer + * cleanup and prevent possible memory leaks. + */ + public fun releaseBuffers() { + resetComputationCount() + releaseBuffersOnThreshold() + retainActiveBufferReferences() + releaseRetainedBuffers() + clearEmptyRetainedBuffers() + } + + private fun resetComputationCount() { + currentZoneComputationCount = 0 + } + + /** + * Checks and forcibly releases retained buffers if the number of unreleased buffers exceeds a predefined threshold. + * + * - **Periodic Forcible Release**: If the total number of retained buffers that could not be released reaches + * 100 ([bufRetentionCountBeforeRelease]), this method will begin to forcibly release these buffers during + * each [releaseBuffers] call to prevent memory leaks. + * + * This mechanism is a safeguard to ensure that buffers are eventually released even in cases where they were not + * properly released due to reference count issues. If this mechanism is triggered, it suggests a deeper + * underlying issue. + */ + private fun releaseBuffersOnThreshold() { + if (retainedBufferReferences.size >= bufRetentionCountBeforeRelease) { + val releaseTarget = retainedBufferReferences.removeFirst() + releaseBuffers(releaseTarget, true) + } + } + + private fun retainActiveBufferReferences() { + if (activeCachedBuffers.isNotEmpty()) { + retainedBufferReferences.addLast(activeCachedBuffers) + activeCachedBuffers = LinkedList() + } + } + + private fun releaseRetainedBuffers() { + for (buffers in retainedBufferReferences) { + releaseBuffers(buffers, false) + } + } + + private fun clearEmptyRetainedBuffers() { + retainedBufferReferences.removeIf { it.isEmpty() } + } + + internal companion object { + private const val DEFAULT_ZONE_COUNT_BEFORE_LEAK_WARNING: Int = 25_000 + internal const val DEFAULT_BUF_RETENTION_COUNT_BEFORE_RELEASE: Int = 100 + + private val supportedEncoders = createEncoderMap() + + private val logger = InlineLogger() + + private fun createClientBufferEnumMap(): EnumMap = + EnumMap(OldSchoolClientType::class.java) + + internal fun createEncoderMap(): ClientTypeMap { + val list = mutableListOf>() + list += OldSchoolClientType.DESKTOP to DesktopUpdateZonePartialEnclosedEncoder + return ClientTypeMap.of(OldSchoolClientType.COUNT, list) + } + + private fun releaseBuffers( + buffers: LinkedList, + forceRelease: Boolean, + ) { + if (buffers.isEmpty()) { + return + } + val iterator = buffers.iterator() + while (iterator.hasNext()) { + val next = iterator.next() + val refCount = next.refCnt() + if (forceRelease) { + // Don't bother removing from the list if force removing, + // let the garbage collector deal with it + if (refCount > 0) { + next.release(refCount) + } + continue + } + if (refCount > 1) { + continue + } + if (refCount == 1) { + next.release() + } + iterator.remove() + } + } + } + } diff --git a/protocol/osrs-236/osrs-236-api/src/test/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBufferTest.kt b/protocol/osrs-236/osrs-236-api/src/test/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBufferTest.kt new file mode 100644 index 000000000..c8b0cbbb8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-api/src/test/kotlin/net/rsprot/protocol/api/util/ZonePartialEnclosedCacheBufferTest.kt @@ -0,0 +1,194 @@ +package net.rsprot.protocol.api.util + +import io.netty.buffer.Unpooled +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.util.OpFlags +import net.rsprot.protocol.game.outgoing.zone.payload.LocAddChangeV2 +import net.rsprot.protocol.game.outgoing.zone.payload.LocAnim +import net.rsprot.protocol.game.outgoing.zone.payload.LocDel +import net.rsprot.protocol.game.outgoing.zone.payload.LocMerge +import net.rsprot.protocol.game.outgoing.zone.payload.MapAnim +import net.rsprot.protocol.game.outgoing.zone.payload.ObjAdd +import net.rsprot.protocol.game.outgoing.zone.payload.ObjCount +import net.rsprot.protocol.game.outgoing.zone.payload.ObjDel +import net.rsprot.protocol.game.outgoing.zone.payload.ObjEnabledOps +import net.rsprot.protocol.game.outgoing.zone.payload.SoundArea +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.message.ZoneProt +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import kotlin.test.Test + +class ZonePartialEnclosedCacheBufferTest { + @Test + fun `computeZone creates buffers for supported clients`() { + val cache = ZonePartialEnclosedCacheBuffer() + val encoders = ZonePartialEnclosedCacheBuffer.createEncoderMap() + val buffers = cache.computeZone(emptyList()) + + assertEquals(encoders.toClientList().toSet(), buffers.keys.toSet()) + + // `computeZone` did not receive any zone prot to encode, so all buffers should be empty. + val expectedBuffers = buffers.map { Unpooled.wrappedBuffer(ByteArray(0)) } + assertEquals(expectedBuffers, buffers.values.toList()) + } + + @Test + fun `computeZoneForClient generates a single buffer for the specified client`() { + val cache = ZonePartialEnclosedCacheBuffer() + val zoneProt = createFullZoneProtList() + val client = OldSchoolClientType.DESKTOP + + val buffer = cache.computeZoneForClient(client, zoneProt) + + // Each zone prot should _at minimum_ write their `indexedEncoder` id. (written as a byte) + // Ensuring every zone prot opcode/payload is written correctly falls out of scope for this test. + val expectedMinReadableBytes = zoneProt.size * Byte.SIZE_BYTES + assertTrue( + buffer.readableBytes() >= expectedMinReadableBytes, + "Expected `$expectedMinReadableBytes` readable bytes from buffer for client: $client. " + + "(bytes=${buffer.readableBytes()})", + ) + + assertEquals( + 1, + cache.currentZoneComputationCount, + "Zone computation should increment by 1 for a single client's buffer.", + ) + assertEquals(1, cache.activeCachedBuffers.size) + } + + @Test + fun `compute zone partial enclosed buffers`() { + val cache = ZonePartialEnclosedCacheBuffer() + + val encoders = ZonePartialEnclosedCacheBuffer.createEncoderMap() + val zoneProt = createFullZoneProtList() + val buffers = cache.computeZone(zoneProt) + + // Each zone prot should _at minimum_ write their `indexedEncoder` id. (written as a byte) + // Ensuring every zone prot opcode/payload is written correctly falls out of scope for this test. + val expectedMinReadableBytes = zoneProt.size * Byte.SIZE_BYTES + for ((client, buffer) in buffers) { + assertTrue(buffer.readableBytes() >= expectedMinReadableBytes) { + "Expected `$expectedMinReadableBytes` readable bytes from " + + "buffer for client: $client. (bytes=${buffer.readableBytes()})" + } + } + + // Each supported client type should have added a buffer to `activeCachedBuffers`. + assertEquals(encoders.toClientList().size, buffers.size) + + // The leak-reference-counter should have been incremented by a single zone. + assertEquals(1, cache.currentZoneComputationCount) + } + + @Test + fun `releaseBuffers resets computation count and releases buffers correctly`() { + val cache = ZonePartialEnclosedCacheBuffer(listOf(OldSchoolClientType.DESKTOP)) + + val emptyBuffer = Unpooled.wrappedBuffer(ByteArray(0)) + cache.activeCachedBuffers += emptyBuffer + cache.currentZoneComputationCount = 1 + + cache.releaseBuffers() + + assertEquals(0, cache.activeCachedBuffers.size) + assertEquals(0, cache.currentZoneComputationCount) + assertEquals(0, cache.retainedBufferReferences.size) + } + + @Test + fun `retain buffers that cannot be released`() { + val cache = ZonePartialEnclosedCacheBuffer(listOf(OldSchoolClientType.DESKTOP)) + + val threshold = ZonePartialEnclosedCacheBuffer.DEFAULT_BUF_RETENTION_COUNT_BEFORE_RELEASE + val retainedBuffers = (0.. + if (buffer.refCnt() > 0) { + buffer.release(buffer.refCnt()) + } + } + } + check(retainedBuffers.all { it.refCnt() == 0 }) + } + + private fun ClientTypeMap.toClientList(): List = + OldSchoolClientType.entries.filter { it in this } + + private fun createFullZoneProtList(): List = + listOf( + LocAddChangeV2(id = 123, xInZone = 0, zInZone = 0, shape = 0, rotation = 0, OpFlags.ALL_SHOWN), + LocAnim(id = 123, xInZone = 0, zInZone = 0, shape = 0, rotation = 0), + LocDel(xInZone = 0, zInZone = 0, shape = 0, rotation = 0), + LocMerge( + index = 0, + id = 123, + xInZone = 0, + zInZone = 0, + shape = 0, + rotation = 0, + start = 0, + end = 0, + minX = 0, + minZ = 0, + maxX = 0, + maxZ = 0, + ), + MapAnim(id = 123, delay = 0, height = 0, xInZone = 0, zInZone = 0), + ObjAdd(id = 123, quantity = 0, xInZone = 0, zInZone = 0, opFlags = OpFlags.ALL_SHOWN), + ObjCount(id = 123, oldQuantity = 0, newQuantity = 0, xInZone = 0, zInZone = 0), + ObjDel(id = 123, quantity = 0, xInZone = 0, zInZone = 0), + ObjEnabledOps(id = 123, opFlags = OpFlags.ALL_SHOWN, xInZone = 0, zInZone = 0), + SoundArea(id = 123, delay = 0, loops = 0, radius = 0, size = 0, xInZone = 0, zInZone = 0), + ) +} diff --git a/protocol/osrs-236/osrs-236-common/build.gradle.kts b/protocol/osrs-236/osrs-236-common/build.gradle.kts new file mode 100644 index 000000000..4ef0301dd --- /dev/null +++ b/protocol/osrs-236/osrs-236-common/build.gradle.kts @@ -0,0 +1,13 @@ +dependencies { + api(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + api(projects.protocol) +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 Common" + description = "The common module for revision 236 OldSchool RuneScape networking, offering " + + "common classes for all the modules to depend on." + } +} diff --git a/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/client/OldSchoolClientType.kt b/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/client/OldSchoolClientType.kt new file mode 100644 index 000000000..67d2a8670 --- /dev/null +++ b/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/client/OldSchoolClientType.kt @@ -0,0 +1,37 @@ +package net.rsprot.protocol.common.client + +import net.rsprot.protocol.client.ClientType + +public enum class OldSchoolClientType( + override val id: Int, +) : ClientType { + /** + * The desktop clients. + * As the protocol is the same between the Java and C++ versions of desktop, + * we use the same client type for both here. + */ + DESKTOP(0), + + /** + * The Android client. + * This is a separate client type as the protocol differs from the desktop clients. + */ + ANDROID(1), + + /** + * The iOS client. + * This is a separate client type as the protocol differs from the desktop clients. + */ + IOS(2), + ; + + public companion object { + /** + * The number of client types that exist. + * This number should be large enough to be used as array capacity, + * as our buffers are often cached per-client type, and we need to use + * client types as the array index. + */ + public const val COUNT: Int = 3 + } +} diff --git a/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/game/outgoing/inv/InventoryObject.kt b/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/game/outgoing/inv/InventoryObject.kt new file mode 100644 index 000000000..93bd4457e --- /dev/null +++ b/protocol/osrs-236/osrs-236-common/src/main/kotlin/net/rsprot/protocol/common/game/outgoing/inv/InventoryObject.kt @@ -0,0 +1,62 @@ +package net.rsprot.protocol.common.game.outgoing.inv + +/** + * Inventory object is a helper object built around the primitive 'long' type. + * We utilize longs directly here to reduce the effects of garbage creation + * via inventories, as this can otherwise get quite severe with a lot of players. + */ +public object InventoryObject { + public const val NULL: Long = -1 + + @JvmSynthetic + public operator fun invoke( + id: Int, + count: Int, + ): Long = pack(0, id, count) + + @JvmSynthetic + public operator fun invoke( + slot: Int, + id: Int, + count: Int, + ): Long = pack(slot, id, count) + + @JvmStatic + public fun pack( + id: Int, + count: Int, + ): Long = pack(0, id, count) + + @JvmStatic + public fun pack( + slot: Int, + id: Int, + count: Int, + ): Long = + (slot.toLong() and 0xFFFF) + .or((id.toLong() and 0xFFFF) shl 16) + .or((count.toLong() and 0xFFFFFFFF) shl 32) + + @JvmStatic + public fun getSlot(packed: Long): Int { + val value = (packed and 0xFFFF).toInt() + return if (value == 0xFFFF) { + -1 + } else { + value + } + } + + @JvmStatic + public fun getId(packed: Long): Int { + val value = (packed ushr 16 and 0xFFFF).toInt() + return if (value == 0xFFFF) { + -1 + } else { + value + } + } + + @JvmStatic + public fun getCount(packed: Long): Int = (packed ushr 32).toInt() +} diff --git a/protocol/osrs-236/osrs-236-desktop/build.gradle.kts b/protocol/osrs-236/osrs-236-desktop/build.gradle.kts new file mode 100644 index 000000000..576e2a2f8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + alias(libs.plugins.jmh) + alias(libs.plugins.allopen) +} + +dependencies { + implementation(rootProject.libs.inline.logger) + api(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + api(rootProject.libs.netty.transport) + api(projects.buffer) + api(projects.compression) + api(projects.crypto) + api(projects.protocol) + api(projects.protocol.osrs236.osrs236Model) + api(projects.protocol.osrs236.osrs236Internal) + api(projects.protocol.osrs236.osrs236Common) +} + +allOpen { + annotation("org.openjdk.jmh.annotations.State") +} + +sourceSets.create("benchmarks") + +kotlin.sourceSets.getByName("benchmarks") { + dependencies { + implementation(rootProject.libs.jmh.runtime) + val mainSourceSet by sourceSets.main + val testSourceSet by sourceSets.test + val sourceSets = listOf(mainSourceSet, testSourceSet) + for (sourceSet in sourceSets) { + implementation(sourceSet.output) + implementation(sourceSet.runtimeClasspath) + } + } +} + +benchmark { + targets { + register("benchmarks") + } + + configurations { + register("PlayerInfoBenchmark") { + include("net.rsprot.protocol.game.outgoing.info.PlayerInfoBenchmark") + } + register("NpcInfoBenchmark") { + include("net.rsprot.protocol.game.outgoing.info.NpcInfoBenchmark") + } + } +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 Desktop" + description = "The desktop module for revision 236 OldSchool RuneScape networking, " + + "offering encoders and decoders for all packets." + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoBenchmark.kt b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoBenchmark.kt new file mode 100644 index 000000000..3fda0ca25 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoBenchmark.kt @@ -0,0 +1,191 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.Unpooled +import io.netty.buffer.UnpooledByteBufAllocator +import net.rsprot.compression.HuffmanCodec +import net.rsprot.compression.provider.DefaultHuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.npcinfo.DesktopLowResolutionChangeEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.writer.NpcAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.info.filter.DefaultExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.npcinfo.DeferredNpcInfoProtocolSupplier +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatar +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarExceptionHandler +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFactory +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfo +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoLargeV5 +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoSmallV5 +import net.rsprot.protocol.game.outgoing.info.util.BuildArea +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS) +@Fork(3) +class NpcInfoBenchmark { + init { + System.setProperty("net.rsprot.protocol.internal.npcPlayerAvatarTracking", "true") + } + + private lateinit var protocol: NpcInfoProtocol + private val random: Random = Random(0) + private lateinit var serverNpcs: List + private lateinit var localNpcInfo: NpcInfo + private lateinit var otherNpcInfos: List + private var localPlayerCoord = CoordGrid(0, 3207, 3207) + private lateinit var factory: NpcAvatarFactory + + @Setup + fun setup() { + val allocator = UnpooledByteBufAllocator.DEFAULT + val storage = + ZoneIndexStorage( + ZoneIndexStorage.NPC_CAPACITY, + ) + val protocolSupplier = DeferredNpcInfoProtocolSupplier() + this.factory = + NpcAvatarFactory( + allocator, + DefaultExtendedInfoFilter(), + listOf(NpcAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + storage, + protocolSupplier, + ) + this.serverNpcs = createPhantomNpcs(factory) + + val encoders = + ClientTypeMap.of( + listOf(DesktopLowResolutionChangeEncoder()), + OldSchoolClientType.COUNT, + ) { + it.clientType + } + protocol = + NpcInfoProtocol( + allocator, + encoders, + factory, + npcExceptionHandler(), + DefaultProtocolWorker(1, ForkJoinPool.commonPool()), + storage, + ) + protocolSupplier.supply(protocol) + this.localNpcInfo = protocol.alloc(1, OldSchoolClientType.DESKTOP) + otherNpcInfos = (2..2046).map { protocol.alloc(it, OldSchoolClientType.DESKTOP) } + val infos = otherNpcInfos + localNpcInfo + for (info in infos) { + info.updateCoord(NpcInfo.ROOT_WORLD, localPlayerCoord.level, localPlayerCoord.x, localPlayerCoord.z) + info.updateBuildArea( + NpcInfo.ROOT_WORLD, + BuildArea( + (localPlayerCoord.x ushr 3) - 6, + (localPlayerCoord.z ushr 3) - 6, + ), + ) + } + } + + private fun npcExceptionHandler(): NpcAvatarExceptionHandler = + NpcAvatarExceptionHandler { _, e -> + e.printStackTrace() + } + + @Benchmark + fun benchmark() { + tick() + } + + private fun tick() { + for (npc in serverNpcs) { + npc.avatar.extendedInfo.setSay("Neque porro quisquam est qui dolorem ipsum quia do") + npc.avatar.teleport( + 0, + random.nextInt(3200, 3213), + random.nextInt(3200, 3213), + true, + ) + } + protocol.update() + for (i in 1..2046) { + val info = protocol[i] + val packet = info.toPacket(NpcInfo.ROOT_WORLD) + packet.markConsumed() + when (packet) { + is NpcInfoSmallV5 -> packet.release() + is NpcInfoLargeV5 -> packet.release() + else -> throw IllegalStateException("Unknown packet type: $packet") + } + } + for (npc in serverNpcs) { + npc.avatar.postUpdate() + } + } + + private fun createPhantomNpcs(factory: NpcAvatarFactory): List { + val npcs = ArrayList(500) + for (index in 0..<500) { + val x = random.nextInt(3200, 3213) + val z = random.nextInt(3200, 3213) + val id = (index * x * z) and 0x3FFF + val coord = CoordGrid(0, x, z) + npcs += + Npc( + index, + id, + factory.alloc( + index, + id, + coord.level, + coord.x, + coord.z, + ), + ) + } + return npcs + } + + private data class Npc( + val index: Int, + val id: Int, + val avatar: NpcAvatar, + ) { + override fun toString(): String = + "Npc(" + + "index=$index, " + + "id=$id, " + + "coordGrid=${avatar.getCoordGrid()}" + + ")" + } + + private companion object { + private fun NpcAvatar.getCoordGrid(): CoordGrid = CoordGrid(level(), x(), z()) + + private fun createHuffmanCodec(): HuffmanCodec { + val resource = PlayerInfoTest::class.java.getResourceAsStream("huffman.dat") + checkNotNull(resource) { + "huffman.dat could not be found" + } + return HuffmanCodec.create(Unpooled.wrappedBuffer(resource.readBytes())) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt new file mode 100644 index 000000000..71988cab7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -0,0 +1,150 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.Unpooled +import io.netty.buffer.UnpooledByteBufAllocator +import net.rsprot.compression.HuffmanCodec +import net.rsprot.compression.provider.DefaultHuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.writer.PlayerAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.info.filter.DefaultExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfo +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol.Companion.PROTOCOL_CAPACITY +import net.rsprot.protocol.game.outgoing.info.util.BuildArea +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS) +@Fork(3) +class PlayerInfoBenchmark { + private lateinit var protocol: PlayerInfoProtocol + private lateinit var players: Array + private val random: Random = Random(0) + + @Setup + fun setup() { + val allocator = UnpooledByteBufAllocator.DEFAULT + val factory = + PlayerAvatarFactory( + allocator, + DefaultExtendedInfoFilter(), + listOf(PlayerAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) + protocol = + PlayerInfoProtocol( + allocator, + DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), + factory, + ) + players = arrayOfNulls(PROTOCOL_CAPACITY) + for (i in 1.. { + override val prot: ClientProt = GameClientProt.IF_BUTTON + + override fun decode(buffer: JagByteBuf): If1Button { + val combinedId = buffer.gCombinedId() + return If1Button(combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonDDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonDDecoder.kt new file mode 100644 index 000000000..ace01ab30 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonDDecoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.codec.buttons + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.buttons.IfButtonD +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt1 +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class IfButtonDDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_BUTTOND + + override fun decode(buffer: JagByteBuf): IfButtonD { + val targetCombinedId = buffer.gCombinedIdAlt3() + val selectedObj = buffer.g2Alt3() + val targetObj = buffer.g2Alt1() + val targetSub = buffer.g2Alt3() + val selectedCombinedId = buffer.gCombinedIdAlt1() + val selectedSub = buffer.g2Alt2() + return IfButtonD( + selectedCombinedId, + selectedSub, + selectedObj, + targetCombinedId, + targetSub, + targetObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonTDecoder.kt new file mode 100644 index 000000000..01e609125 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonTDecoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.codec.buttons + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.buttons.IfButtonT +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt2 +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class IfButtonTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_BUTTONT + + override fun decode(buffer: JagByteBuf): IfButtonT { + val targetSub = buffer.g2() + val selectedObj = buffer.g2Alt1() + val selectedSub = buffer.g2Alt2() + val selectedCombinedId = buffer.gCombinedIdAlt2() + val targetObj = buffer.g2Alt3() + val targetCombinedId = buffer.gCombinedIdAlt3() + return IfButtonT( + selectedCombinedId, + selectedSub, + selectedObj, + targetCombinedId, + targetSub, + targetObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonXDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonXDecoder.kt new file mode 100644 index 000000000..ecf018dc3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfButtonXDecoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.buttons + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.buttons.If3Button +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent +import net.rsprot.protocol.util.gCombinedId + +@Consistent +public class IfButtonXDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_BUTTONX + + override fun decode(buffer: JagByteBuf): If3Button { + val combinedId = buffer.gCombinedId() + val sub = buffer.g2() + val obj = buffer.g2() + val op = buffer.g1() + return If3Button( + combinedId, + sub, + obj, + op, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfRunScriptDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfRunScriptDecoder.kt new file mode 100644 index 000000000..9659f78f5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfRunScriptDecoder.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.game.incoming.codec.buttons + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.buttons.IfRunScript +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class IfRunScriptDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_RUNSCRIPT + + override fun decode(buffer: JagByteBuf): IfRunScript { + // Function is method(int combinedId, int sub, int obj, int script, Object[] args) + // The order of argument does not seem to change (based on two revisions) + val obj = buffer.g2Alt1() + val combinedId = buffer.gCombinedIdAlt3() + val script = buffer.g4Alt2() + val sub = buffer.g2Alt2() + + val copy = buffer.buffer.copy() + // Mark the buffer as "read" as copy function doesn't do it automatically. + buffer.buffer.readerIndex(buffer.buffer.writerIndex()) + return IfRunScript( + combinedId, + sub, + obj, + script, + copy, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfSubOpDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfSubOpDecoder.kt new file mode 100644 index 000000000..2872b265d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/buttons/IfSubOpDecoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.codec.buttons + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.buttons.IfSubOp +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent +import net.rsprot.protocol.util.gCombinedId + +@Consistent +public class IfSubOpDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_SUBOP + + override fun decode(buffer: JagByteBuf): IfSubOp { + val combinedId = buffer.gCombinedId() + val sub = buffer.g2() + val obj = buffer.g2() + val op = buffer.g1() + val subop = buffer.g1() + return IfSubOp( + combinedId, + sub, + obj, + op, + subop, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsAddBannedFromChannelDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsAddBannedFromChannelDecoder.kt new file mode 100644 index 000000000..9f5fd345f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsAddBannedFromChannelDecoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.clan.AffinedClanSettingsAddBannedFromChannel +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class AffinedClanSettingsAddBannedFromChannelDecoder : + MessageDecoder { + override val prot: ClientProt = GameClientProt.AFFINEDCLANSETTINGS_ADDBANNED_FROMCHANNEL + + override fun decode(buffer: JagByteBuf): AffinedClanSettingsAddBannedFromChannel { + val clanId = buffer.g1() + val memberIndex = buffer.g2() + val name = buffer.gjstr() + return AffinedClanSettingsAddBannedFromChannel( + name, + clanId, + memberIndex, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsSetMutedFromChannelDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsSetMutedFromChannelDecoder.kt new file mode 100644 index 000000000..8def581c3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/AffinedClanSettingsSetMutedFromChannelDecoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.clan.AffinedClanSettingsSetMutedFromChannel +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class AffinedClanSettingsSetMutedFromChannelDecoder : + MessageDecoder { + override val prot: ClientProt = GameClientProt.AFFINEDCLANSETTINGS_SETMUTED_FROMCHANNEL + + override fun decode(buffer: JagByteBuf): AffinedClanSettingsSetMutedFromChannel { + val clanId = buffer.g1() + val memberIndex = buffer.g2() + val muted = buffer.g1() == 1 + val name = buffer.gjstr() + return AffinedClanSettingsSetMutedFromChannel( + name, + clanId, + memberIndex, + muted, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelFullRequestDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelFullRequestDecoder.kt new file mode 100644 index 000000000..baf2d5efe --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelFullRequestDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.clan.ClanChannelFullRequest +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanChannelFullRequestDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLANCHANNEL_FULL_REQUEST + + override fun decode(buffer: JagByteBuf): ClanChannelFullRequest { + val clanId = buffer.g1s() + return ClanChannelFullRequest(clanId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelKickUserDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelKickUserDecoder.kt new file mode 100644 index 000000000..e2c1d2e63 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanChannelKickUserDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.clan.ClanChannelKickUser +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanChannelKickUserDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLANCHANNEL_KICKUSER + + override fun decode(buffer: JagByteBuf): ClanChannelKickUser { + val clanId = buffer.g1() + val memberIndex = buffer.g2() + val name = buffer.gjstr() + return ClanChannelKickUser( + name, + clanId, + memberIndex, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanSettingsFullRequestDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanSettingsFullRequestDecoder.kt new file mode 100644 index 000000000..548faf22a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/clan/ClanSettingsFullRequestDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.clan.ClanSettingsFullRequest +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanSettingsFullRequestDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLANSETTINGS_FULL_REQUEST + + override fun decode(buffer: JagByteBuf): ClanSettingsFullRequest { + val clanId = buffer.g1s() + return ClanSettingsFullRequest(clanId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventAppletFocusDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventAppletFocusDecoder.kt new file mode 100644 index 000000000..2ebfd4fe1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventAppletFocusDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventAppletFocus +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class EventAppletFocusDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_APPLET_FOCUS + + override fun decode(buffer: JagByteBuf): EventAppletFocus { + val inFocus = buffer.g1() == 1 + return EventAppletFocus(inFocus) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventCameraPositionDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventCameraPositionDecoder.kt new file mode 100644 index 000000000..a637cfa5f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventCameraPositionDecoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventCameraPosition +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class EventCameraPositionDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_CAMERA_POSITION + + override fun decode(buffer: JagByteBuf): EventCameraPosition { + val angleX = buffer.g2Alt1() + val angleY = buffer.g2Alt3() + return EventCameraPosition( + angleX, + angleY, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventKeyboardDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventKeyboardDecoder.kt new file mode 100644 index 000000000..8157c74df --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventKeyboardDecoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventKeyboard +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class EventKeyboardDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_KEYBOARD + + override fun decode(buffer: JagByteBuf): EventKeyboard { + val count = buffer.readableBytes() / 4 + val keys = ByteArray(count) + var lastTransmittedKeyPress: Int = -1 + for (i in 0.. { + override val prot: ClientProt = GameClientProt.EVENT_MOUSE_CLICK_V1 + + override fun decode(buffer: JagByteBuf): EventMouseClickV1 { + val packed = buffer.g2() + val rightClick = packed and 0x1 != 0 + val lastTransmittedMouseClick = packed ushr 1 + val x = buffer.g2() + val y = buffer.g2() + return EventMouseClickV1( + lastTransmittedMouseClick, + rightClick, + x, + y, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseClickV2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseClickV2Decoder.kt new file mode 100644 index 000000000..d64e6d90d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseClickV2Decoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventMouseClickV2 +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class EventMouseClickV2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_MOUSE_CLICK_V2 + + override fun decode(buffer: JagByteBuf): EventMouseClickV2 { + val code = buffer.g1Alt2() + val y = buffer.g2Alt2() + val x = buffer.g2Alt2() + val packed = buffer.g2Alt1() + val rightClick = packed and 0x1 != 0 + val lastTransmittedMouseClick = packed ushr 1 + return EventMouseClickV2( + lastTransmittedMouseClick, + code, + rightClick, + x, + y, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseMoveDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseMoveDecoder.kt new file mode 100644 index 000000000..20fbe94cb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseMoveDecoder.kt @@ -0,0 +1,92 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventMouseMove +import net.rsprot.protocol.game.incoming.events.util.MouseMovements +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Suppress("DuplicatedCode") +@Consistent +public class EventMouseMoveDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_MOUSE_MOVE + + override fun decode(buffer: JagByteBuf): EventMouseMove { + val stepExcess = buffer.g1() + val endExcess = buffer.g1() + val array = threadLocalArray.get() + var count = 0 + while (buffer.isReadable) { + var packed = buffer.g1() + var x: Int + var y: Int + var timeSinceLastMovement: Int + var delta: Boolean + if (packed and 0xE0 == 0xE0) { + timeSinceLastMovement = packed and 0x1f shl 8 or buffer.g1() + val packed = buffer.g4() + if (packed == Int.MIN_VALUE) { + x = -1 + y = -1 + } else { + x = packed and 0xFFFF + y = packed ushr 16 and 0xFFFF + } + delta = false + } else if (packed and 0xC0 == 0xC0) { + timeSinceLastMovement = packed and 0x3f + + val packed = buffer.g4() + if (packed == Int.MIN_VALUE) { + x = -1 + y = -1 + } else { + x = packed and 0xFFFF + y = packed ushr 16 and 0xFFFF + } + delta = false + } else if (packed and 0x80 == 0x80) { + timeSinceLastMovement = packed and 0x7f + x = buffer.g1() - 128 + y = buffer.g1() - 128 + delta = true + } else { + packed = (packed shl 8) or (buffer.g1()) + timeSinceLastMovement = (packed ushr 12) and 0x7 + x = ((packed shr 6) and 0x3F) - 32 + y = (packed and 0x3F) - 32 + delta = true + } + val change = + MouseMovements.MousePosChange.pack( + timeSinceLastMovement, + x, + y, + delta, + ) + array[count++] = change + } + val slice = array.copyOf(count) + return EventMouseMove( + stepExcess, + endExcess, + MouseMovements(slice), + ) + } + + private companion object { + /** + * Utilizing a thread-local initial long array, as the number of + * mouse movements is unknown (relies on remaining bytes in buffer, + * which in turn uses compression methods so each entry can be 2-4 bytes). + * As Netty's threads decode this, a thread-local implementation is + * perfectly safe to utilize, and will save us some memory in return. + */ + private val threadLocalArray = + ThreadLocal.withInitial { + LongArray(128) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseScrollDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseScrollDecoder.kt new file mode 100644 index 000000000..d0e4102e7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventMouseScrollDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventMouseScroll +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class EventMouseScrollDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_MOUSE_SCROLL + + override fun decode(buffer: JagByteBuf): EventMouseScroll { + val rotation = buffer.g2s() + return EventMouseScroll(rotation) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventNativeMouseMoveDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventNativeMouseMoveDecoder.kt new file mode 100644 index 000000000..1f6272d83 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/events/EventNativeMouseMoveDecoder.kt @@ -0,0 +1,89 @@ +package net.rsprot.protocol.game.incoming.codec.events + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.events.EventNativeMouseMove +import net.rsprot.protocol.game.incoming.events.util.MouseMovements +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Suppress("DuplicatedCode") +@Consistent +public class EventNativeMouseMoveDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.EVENT_NATIVE_MOUSE_MOVE + + override fun decode(buffer: JagByteBuf): EventNativeMouseMove { + val stepExcess = buffer.g1() + val endExcess = buffer.g1() + val array = threadLocalArray.get() + var count = 0 + while (buffer.isReadable) { + var packed = buffer.g1() + var x: Int + var y: Int + var timeSinceLastMovement: Int + var delta: Boolean + if (packed and 0xE0 == 0xE0) { + timeSinceLastMovement = packed and 0x1f shl 8 or buffer.g1() + y = buffer.g2s() + x = buffer.g2s() + delta = false + if (x == 0 && y == -0x8000) { + x = -1 + y = -1 + } + } else if (packed and 0xC0 == 0xC0) { + timeSinceLastMovement = packed and 0x3f + y = buffer.g2s() + x = buffer.g2s() + delta = false + if (x == 0 && y == -0x8000) { + x = -1 + y = -1 + } + } else if (packed and 0x80 == 0x80) { + timeSinceLastMovement = packed and 0x7f + x = buffer.g1() - 128 + y = buffer.g1() - 128 + delta = true + } else { + packed = (packed shl 8) or (buffer.g1()) + timeSinceLastMovement = (packed ushr 12) and 0x7 + x = ((packed shr 6) and 0x3F) - 32 + y = (packed and 0x3F) - 32 + delta = true + } + val lastMouseButton = buffer.g1() + val change = + MouseMovements.MousePosChange.pack( + timeSinceLastMovement, + x, + y, + delta, + lastMouseButton, + ) + array[count++] = change + } + val slice = array.copyOf(count) + return EventNativeMouseMove( + stepExcess, + endExcess, + MouseMovements(slice), + ) + } + + private companion object { + /** + * Utilizing a thread-local initial long array, as the number of + * mouse movements is unknown (relies on remaining bytes in buffer, + * which in turn uses compression methods so each entry can be 2-4 bytes). + * As Netty's threads decode this, a thread-local implementation is + * perfectly safe to utilize, and will save us some memory in return. + */ + private val threadLocalArray = + ThreadLocal.withInitial { + LongArray(128) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatJoinLeaveDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatJoinLeaveDecoder.kt new file mode 100644 index 000000000..44dc7c68a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatJoinLeaveDecoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.incoming.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.friendchat.FriendChatJoinLeave +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class FriendChatJoinLeaveDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.FRIENDCHAT_JOIN_LEAVE + + override fun decode(buffer: JagByteBuf): FriendChatJoinLeave { + val name = + if (!buffer.isReadable) { + null + } else { + buffer.gjstr() + } + return FriendChatJoinLeave(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatKickDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatKickDecoder.kt new file mode 100644 index 000000000..c611d9631 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatKickDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.friendchat.FriendChatKick +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class FriendChatKickDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.FRIENDCHAT_KICK + + override fun decode(buffer: JagByteBuf): FriendChatKick { + val name = buffer.gjstr() + return FriendChatKick(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatSetRankDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatSetRankDecoder.kt new file mode 100644 index 000000000..0e163cc79 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/friendchat/FriendChatSetRankDecoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.incoming.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.friendchat.FriendChatSetRank +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class FriendChatSetRankDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.FRIENDCHAT_SETRANK + + override fun decode(buffer: JagByteBuf): FriendChatSetRank { + val rank = buffer.g1Alt1() + val name = buffer.gjstr() + return FriendChatSetRank( + name, + rank, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc1Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc1Decoder.kt new file mode 100644 index 000000000..5c5200d9a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc1Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc1Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC1 + + override fun decode(buffer: JagByteBuf): OpLoc { + val controlKey = buffer.g1Alt1() == 1 + val z = buffer.g2Alt3() + val id = buffer.g2() + val x = buffer.g2Alt2() + return OpLoc( + id, + x, + z, + controlKey, + 1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc2Decoder.kt new file mode 100644 index 000000000..2e3d74b1e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc2Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC2 + + override fun decode(buffer: JagByteBuf): OpLoc { + val x = buffer.g2() + val id = buffer.g2Alt3() + val z = buffer.g2() + val controlKey = buffer.g1() == 1 + return OpLoc( + id, + x, + z, + controlKey, + 2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc3Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc3Decoder.kt new file mode 100644 index 000000000..377891e9d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc3Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc3Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC3 + + override fun decode(buffer: JagByteBuf): OpLoc { + val id = buffer.g2Alt2() + val z = buffer.g2Alt1() + val x = buffer.g2() + val controlKey = buffer.g1Alt2() == 1 + return OpLoc( + id, + x, + z, + controlKey, + 3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc4Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc4Decoder.kt new file mode 100644 index 000000000..0b0bdc38d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc4Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc4Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC4 + + override fun decode(buffer: JagByteBuf): OpLoc { + val z = buffer.g2() + val controlKey = buffer.g1() == 1 + val id = buffer.g2Alt1() + val x = buffer.g2Alt2() + return OpLoc( + id, + x, + z, + controlKey, + 4, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc5Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc5Decoder.kt new file mode 100644 index 000000000..8b9b948cb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc5Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc5Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC5 + + override fun decode(buffer: JagByteBuf): OpLoc { + val id = buffer.g2Alt3() + val z = buffer.g2Alt1() + val controlKey = buffer.g1() == 1 + val x = buffer.g2Alt2() + return OpLoc( + id, + x, + z, + controlKey, + 5, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc6Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc6Decoder.kt new file mode 100644 index 000000000..6bd0afe23 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLoc6Decoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLoc6 +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpLoc6Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOC6 + + override fun decode(buffer: JagByteBuf): OpLoc6 { + val id = buffer.g2Alt3() + return OpLoc6(id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLocTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLocTDecoder.kt new file mode 100644 index 000000000..35a779d2b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/locs/OpLocTDecoder.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.codec.locs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.locs.OpLocT +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt2 + +public class OpLocTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPLOCT + + override fun decode(buffer: JagByteBuf): OpLocT { + val selectedSub = buffer.g2Alt3() + val x = buffer.g2Alt3() + val selectedCombinedId = buffer.gCombinedIdAlt2() + val controlKey = buffer.g1Alt1() == 1 + val id = buffer.g2() + val z = buffer.g2() + val selectedObj = buffer.g2Alt2() + return OpLocT( + id, + x, + z, + controlKey, + selectedCombinedId, + selectedSub, + selectedObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePrivateDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePrivateDecoder.kt new file mode 100644 index 000000000..66ce13b36 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePrivateDecoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.incoming.codec.messaging + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.messaging.MessagePrivate +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessagePrivateDecoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageDecoder { + override val prot: ClientProt = GameClientProt.MESSAGE_PRIVATE + + override fun decode(buffer: JagByteBuf): MessagePrivate { + val name = buffer.gjstr() + val huffman = huffmanCodecProvider.provide() + val message = huffman.decode(buffer) + return MessagePrivate( + name, + message, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePublicDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePublicDecoder.kt new file mode 100644 index 000000000..b476088e6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/messaging/MessagePublicDecoder.kt @@ -0,0 +1,63 @@ +package net.rsprot.protocol.game.incoming.codec.messaging + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.messaging.MessagePublic +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessagePublicDecoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageDecoder { + override val prot: ClientProt = GameClientProt.MESSAGE_PUBLIC + + override fun decode(buffer: JagByteBuf): MessagePublic { + val type = buffer.g1() + val colour = buffer.g1() + val effect = buffer.g1() + val patternArray = + if (colour in 13..20) { + ByteArray(colour - 12) { + buffer.g1().toByte() + } + } else { + null + } + val huffman = huffmanCodecProvider.provide() + val hasTrailingByte = type == CLAN_MAIN_CHANNEL_TYPE + val huffmanSlice = + if (hasTrailingByte) { + buffer.buffer.readSlice(buffer.readableBytes() - 1) + } else { + buffer.buffer + } + val message = huffman.decode(huffmanSlice) + val clanType = + if (hasTrailingByte) { + buffer.g1() + } else { + -1 + } + val pattern = + if (patternArray != null) { + MessagePublic.MessageColourPattern(patternArray) + } else { + null + } + return MessagePublic( + type, + colour, + effect, + message, + pattern, + clanType, + ) + } + + private companion object { + private const val CLAN_MAIN_CHANNEL_TYPE: Int = 3 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ConnectionTelemetryDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ConnectionTelemetryDecoder.kt new file mode 100644 index 000000000..1ff9efd68 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ConnectionTelemetryDecoder.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.ConnectionTelemetry +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ConnectionTelemetryDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CONNECTION_TELEMETRY + + override fun decode(buffer: JagByteBuf): ConnectionTelemetry { + val connectionLostDuration = buffer.g2() + val loginDuration = buffer.g2() + val unusedDuration = buffer.g2() + check(unusedDuration == 0) { + "Unknown duration detected: $unusedDuration" + } + val clientState = buffer.g2() + val unused1 = buffer.g2() + check(unused1 == 0) { + "Unused1 property value detected: $unused1" + } + val loginCount = buffer.g2() + val unused2 = buffer.g2() + check(unused2 == 0) { + "Unused2 property value detected: $unused2" + } + return ConnectionTelemetry( + connectionLostDuration, + loginDuration, + clientState, + loginCount, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/DetectModifiedClientDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/DetectModifiedClientDecoder.kt new file mode 100644 index 000000000..c06a2e48a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/DetectModifiedClientDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.DetectModifiedClient +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class DetectModifiedClientDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.DETECT_MODIFIED_CLIENT + + override fun decode(buffer: JagByteBuf): DetectModifiedClient { + val code = buffer.g4() + return DetectModifiedClient(code) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/IdleDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/IdleDecoder.kt new file mode 100644 index 000000000..2f9d93cfe --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/IdleDecoder.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.Idle +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class IdleDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IDLE + + override fun decode(buffer: JagByteBuf): Idle = Idle +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MapBuildCompleteDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MapBuildCompleteDecoder.kt new file mode 100644 index 000000000..14fd735eb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MapBuildCompleteDecoder.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.MapBuildComplete +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MapBuildCompleteDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.MAP_BUILD_COMPLETE + + override fun decode(buffer: JagByteBuf): MapBuildComplete = MapBuildComplete +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MembershipPromotionEligibilityDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MembershipPromotionEligibilityDecoder.kt new file mode 100644 index 000000000..997ba3308 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/MembershipPromotionEligibilityDecoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.MembershipPromotionEligibility +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MembershipPromotionEligibilityDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.MEMBERSHIP_PROMOTION_ELIGIBILITY + + override fun decode(buffer: JagByteBuf): MembershipPromotionEligibility { + val eligibleForIntroductoryPrice = buffer.g1() + val eligibleForTrialPurchase = buffer.g1() + return MembershipPromotionEligibility( + eligibleForIntroductoryPrice, + eligibleForTrialPurchase, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/NoTimeoutDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/NoTimeoutDecoder.kt new file mode 100644 index 000000000..3e6fb0cf7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/NoTimeoutDecoder.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.NoTimeout +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class NoTimeoutDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.NO_TIMEOUT + + override fun decode(buffer: JagByteBuf): NoTimeout = NoTimeout +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/RSevenStatusDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/RSevenStatusDecoder.kt new file mode 100644 index 000000000..d1ece4d11 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/RSevenStatusDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.RSevenStatus +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class RSevenStatusDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RSEVEN_STATUS + + override fun decode(buffer: JagByteBuf): RSevenStatus { + val packed = buffer.g1() + return RSevenStatus(packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ReflectionCheckReplyDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ReflectionCheckReplyDecoder.kt new file mode 100644 index 000000000..57d6ae15a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/ReflectionCheckReplyDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.ReflectionCheckReply +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ReflectionCheckReplyDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.REFLECTION_CHECK_REPLY + + override fun decode(buffer: JagByteBuf): ReflectionCheckReply { + val copy = buffer.buffer.copy() + val id = buffer.g4() + // Mark the buffer as "read" as copy function doesn't do it automatically. + buffer.buffer.readerIndex(buffer.buffer.writerIndex()) + return ReflectionCheckReply( + id, + copy, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SendPingReplyDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SendPingReplyDecoder.kt new file mode 100644 index 000000000..4a54707e8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SendPingReplyDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.SendPingReply +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class SendPingReplyDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.SEND_PING_REPLY + + override fun decode(buffer: JagByteBuf): SendPingReply { + val value1 = buffer.g4() + val value2 = buffer.g4Alt3() + val gcPercentTime = buffer.g1Alt1() + val fps = buffer.g1Alt3() + return SendPingReply( + fps, + gcPercentTime, + value1, + value2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SoundJingleEndDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SoundJingleEndDecoder.kt new file mode 100644 index 000000000..98bc5e212 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/SoundJingleEndDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.SoundJingleEnd +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SoundJingleEndDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.SOUND_JINGLEEND + + override fun decode(buffer: JagByteBuf): SoundJingleEnd { + val jingle = buffer.g4() + return SoundJingleEnd(jingle) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/WindowStatusDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/WindowStatusDecoder.kt new file mode 100644 index 000000000..f6c131a7a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/client/WindowStatusDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.client.WindowStatus +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class WindowStatusDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.WINDOW_STATUS + + override fun decode(buffer: JagByteBuf): WindowStatus { + val windowMode = buffer.g1() + val frameWidth = buffer.g2() + val frameHeight = buffer.g2() + return WindowStatus( + windowMode, + frameWidth, + frameHeight, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/BugReportDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/BugReportDecoder.kt new file mode 100644 index 000000000..d237309ff --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/BugReportDecoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.BugReport +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class BugReportDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.BUG_REPORT + + override fun decode(buffer: JagByteBuf): BugReport { + val description = buffer.gjstr() + val type = buffer.g1Alt3() + val instructions = buffer.gjstr() + check(description.length <= 500) { + "Bug report description length cannot exceed 500 characters." + } + check(instructions.length <= 500) { + "Bug report instructions length cannot exceed 500 characters." + } + return BugReport( + type, + description, + instructions, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClickWorldMapDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClickWorldMapDecoder.kt new file mode 100644 index 000000000..cd6a01587 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClickWorldMapDecoder.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.ClickWorldMap +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.codec.MessageDecoder + +public class ClickWorldMapDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLICKWORLDMAP + + override fun decode(buffer: JagByteBuf): ClickWorldMap { + val packed = buffer.g4() + return ClickWorldMap(CoordGrid(packed)) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClientCheatDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClientCheatDecoder.kt new file mode 100644 index 000000000..d22f09ed7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/ClientCheatDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.ClientCheat +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClientCheatDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLIENT_CHEAT + + override fun decode(buffer: JagByteBuf): ClientCheat { + val command = buffer.gjstr() + return ClientCheat(command) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/CloseModalDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/CloseModalDecoder.kt new file mode 100644 index 000000000..828e2ec57 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/CloseModalDecoder.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.CloseModal +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CloseModalDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.CLOSE_MODAL + + override fun decode(buffer: JagByteBuf): CloseModal = CloseModal +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/HiscoreRequestDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/HiscoreRequestDecoder.kt new file mode 100644 index 000000000..4670d19e2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/HiscoreRequestDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.HiscoreRequest +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class HiscoreRequestDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.HISCORE_REQUEST + + override fun decode(buffer: JagByteBuf): HiscoreRequest { + val requestId = buffer.g1() + val type = buffer.g1() + val name = buffer.gjstr() + return HiscoreRequest( + type, + requestId, + name, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/IfCrmViewClickDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/IfCrmViewClickDecoder.kt new file mode 100644 index 000000000..089a07a87 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/IfCrmViewClickDecoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.IfCrmViewClick +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt2 + +public class IfCrmViewClickDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IF_CRMVIEW_CLICK + + override fun decode(buffer: JagByteBuf): IfCrmViewClick { + val serverTarget = buffer.g4Alt1() + val sub = buffer.g2Alt2() + val behaviour3 = buffer.g4Alt2() + val behaviour2 = buffer.g4() + val behaviour1 = buffer.g4Alt1() + val combinedId = buffer.gCombinedIdAlt2() + return IfCrmViewClick( + serverTarget, + combinedId, + sub, + behaviour1, + behaviour2, + behaviour3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveGameClickDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveGameClickDecoder.kt new file mode 100644 index 000000000..5b5a3a63d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveGameClickDecoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.MoveGameClick +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class MoveGameClickDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.MOVE_GAMECLICK + + override fun decode(buffer: JagByteBuf): MoveGameClick { + val z = buffer.g2Alt3() + val x = buffer.g2() + val keyCombination = buffer.g1Alt3() + return MoveGameClick( + x, + z, + keyCombination, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveMinimapClickDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveMinimapClickDecoder.kt new file mode 100644 index 000000000..b411a6e4e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/MoveMinimapClickDecoder.kt @@ -0,0 +1,55 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.MoveMinimapClick +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class MoveMinimapClickDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.MOVE_MINIMAPCLICK + + override fun decode(buffer: JagByteBuf): MoveMinimapClick { + // The x, z and keyCombination get scrambled between revisions + val z = buffer.g2Alt3() + val x = buffer.g2() + val keyCombination = buffer.g1Alt3() + + // The arguments below are consistent across revisions + val minimapWidth = buffer.g1() + val minimapHeight = buffer.g1() + val cameraAngleY = buffer.g2() + val checkpoint1 = buffer.g1() + check(checkpoint1 == 57) { + "Invalid checkpoint 1: $checkpoint1" + } + val checkpoint2 = buffer.g1() + check(checkpoint2 == 0) { + "Invalid checkpoint 2: $checkpoint2" + } + val checkpoint3 = buffer.g1() + check(checkpoint3 == 0) { + "Invalid checkpoint 3: $checkpoint3" + } + val checkpoint4 = buffer.g1() + check(checkpoint4 == 89) { + "Invalid checkpoint 4: $checkpoint4" + } + val fineX = buffer.g2() + val fineZ = buffer.g2() + val checkpoint5 = buffer.g1() + check(checkpoint5 == 63) { + "Invalid checkpoint 5: $checkpoint5" + } + return MoveMinimapClick( + x, + z, + keyCombination, + minimapWidth, + minimapHeight, + cameraAngleY, + fineX, + fineZ, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/OculusLeaveDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/OculusLeaveDecoder.kt new file mode 100644 index 000000000..a6c01f1ed --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/OculusLeaveDecoder.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.OculusLeave +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class OculusLeaveDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OCULUS_LEAVE + + override fun decode(buffer: JagByteBuf): OculusLeave = OculusLeave +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SendSnapshotDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SendSnapshotDecoder.kt new file mode 100644 index 000000000..01aee4eaa --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SendSnapshotDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.SendSnapshot +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SendSnapshotDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.SEND_SNAPSHOT + + override fun decode(buffer: JagByteBuf): SendSnapshot { + val name = buffer.gjstr() + val ruleId = buffer.g1() + val mute = buffer.g1() == 1 + return SendSnapshot( + name, + ruleId, + mute, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetChatFilterSettingsDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetChatFilterSettingsDecoder.kt new file mode 100644 index 000000000..a4e3ebd1f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetChatFilterSettingsDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.SetChatFilterSettings +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetChatFilterSettingsDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.SET_CHATFILTERSETTINGS + + override fun decode(buffer: JagByteBuf): SetChatFilterSettings { + val publicChatFilter = buffer.g1() + val privateChatFilter = buffer.g1() + val tradeChatFilter = buffer.g1() + return SetChatFilterSettings( + publicChatFilter, + privateChatFilter, + tradeChatFilter, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetHeadingDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetHeadingDecoder.kt new file mode 100644 index 000000000..fa645efca --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/SetHeadingDecoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.SetHeading +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class SetHeadingDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.SET_HEADING + + override fun decode(buffer: JagByteBuf): SetHeading { + val heading = buffer.g1Alt1() + return SetHeading(heading) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/TeleportDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/TeleportDecoder.kt new file mode 100644 index 000000000..95b5b001a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/misc/user/TeleportDecoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.incoming.codec.misc.user + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.misc.user.Teleport +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class TeleportDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.TELEPORT + + override fun decode(buffer: JagByteBuf): Teleport { + val level = buffer.g1() + val z = buffer.g2Alt3() + val oculusSyncValue = buffer.g4Alt1() + val x = buffer.g2() + return Teleport( + oculusSyncValue, + x, + z, + level, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc1Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc1Decoder.kt new file mode 100644 index 000000000..57a52f590 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc1Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc1Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC1 + + override fun decode(buffer: JagByteBuf): OpNpc { + val index = buffer.g2Alt1() + val controlKey = buffer.g1Alt1() == 1 + return OpNpc( + index, + controlKey, + 1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc2Decoder.kt new file mode 100644 index 000000000..24cd35ca1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc2Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC2 + + override fun decode(buffer: JagByteBuf): OpNpc { + val index = buffer.g2() + val controlKey = buffer.g1Alt2() == 1 + return OpNpc( + index, + controlKey, + 2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc3Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc3Decoder.kt new file mode 100644 index 000000000..849177ea1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc3Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc3Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC3 + + override fun decode(buffer: JagByteBuf): OpNpc { + val index = buffer.g2Alt1() + val controlKey = buffer.g1Alt1() == 1 + return OpNpc( + index, + controlKey, + 3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc4Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc4Decoder.kt new file mode 100644 index 000000000..be9169dae --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc4Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc4Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC4 + + override fun decode(buffer: JagByteBuf): OpNpc { + val index = buffer.g2Alt1() + val controlKey = buffer.g1Alt2() == 1 + return OpNpc( + index, + controlKey, + 4, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc5Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc5Decoder.kt new file mode 100644 index 000000000..79845d87c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc5Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc5Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC5 + + override fun decode(buffer: JagByteBuf): OpNpc { + val index = buffer.g2Alt1() + val controlKey = buffer.g1Alt3() == 1 + return OpNpc( + index, + controlKey, + 5, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc6Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc6Decoder.kt new file mode 100644 index 000000000..f97819c8c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpc6Decoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpc6 +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpNpc6Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPC6 + + override fun decode(buffer: JagByteBuf): OpNpc6 { + val id = buffer.g2Alt1() + return OpNpc6(id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpcTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpcTDecoder.kt new file mode 100644 index 000000000..4c1e451f6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/npcs/OpNpcTDecoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.npcs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.npcs.OpNpcT +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class OpNpcTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPNPCT + + override fun decode(buffer: JagByteBuf): OpNpcT { + val index = buffer.g2Alt2() + val selectedSub = buffer.g2Alt3() + val selectedCombinedId = buffer.gCombinedIdAlt3() + val controlKey = buffer.g1Alt1() == 1 + val selectedObj = buffer.g2Alt2() + return OpNpcT( + index, + controlKey, + selectedCombinedId, + selectedSub, + selectedObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj1Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj1Decoder.kt new file mode 100644 index 000000000..9a93e120d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj1Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj1Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ1 + + override fun decode(buffer: JagByteBuf): OpObj { + val id = buffer.g2() + val x = buffer.g2Alt1() + val z = buffer.g2() + val controlKey = buffer.g1Alt3() == 1 + return OpObj( + id, + x, + z, + controlKey, + 1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj2Decoder.kt new file mode 100644 index 000000000..07f4c2468 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj2Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ2 + + override fun decode(buffer: JagByteBuf): OpObj { + val x = buffer.g2Alt2() + val controlKey = buffer.g1Alt1() == 1 + val z = buffer.g2Alt1() + val id = buffer.g2Alt1() + return OpObj( + id, + x, + z, + controlKey, + 2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj3Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj3Decoder.kt new file mode 100644 index 000000000..32b008732 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj3Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj3Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ3 + + override fun decode(buffer: JagByteBuf): OpObj { + val id = buffer.g2Alt1() + val z = buffer.g2Alt2() + val controlKey = buffer.g1Alt2() == 1 + val x = buffer.g2() + return OpObj( + id, + x, + z, + controlKey, + 3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj4Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj4Decoder.kt new file mode 100644 index 000000000..9a89b7c0f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj4Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj4Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ4 + + override fun decode(buffer: JagByteBuf): OpObj { + val z = buffer.g2Alt1() + val controlKey = buffer.g1Alt2() == 1 + val id = buffer.g2() + val x = buffer.g2() + return OpObj( + id, + x, + z, + controlKey, + 4, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj5Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj5Decoder.kt new file mode 100644 index 000000000..2c4e6af89 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj5Decoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj5Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ5 + + override fun decode(buffer: JagByteBuf): OpObj { + val z = buffer.g2Alt3() + val controlKey = buffer.g1Alt2() == 1 + val x = buffer.g2Alt1() + val id = buffer.g2Alt2() + return OpObj( + id, + x, + z, + controlKey, + 5, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj6Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj6Decoder.kt new file mode 100644 index 000000000..ea0aee828 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObj6Decoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObj6 +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpObj6Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJ6 + + override fun decode(buffer: JagByteBuf): OpObj6 { + val z = buffer.g2Alt1() + val id = buffer.g2Alt2() + val x = buffer.g2Alt2() + return OpObj6( + id, + x, + z, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObjTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObjTDecoder.kt new file mode 100644 index 000000000..c3b170014 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/objs/OpObjTDecoder.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.codec.objs + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.objs.OpObjT +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt2 + +public class OpObjTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPOBJT + + override fun decode(buffer: JagByteBuf): OpObjT { + val selectedCombinedId = buffer.gCombinedIdAlt2() + val z = buffer.g2Alt2() + val id = buffer.g2Alt3() + val controlKey = buffer.g1Alt2() == 1 + val selectedObj = buffer.g2() + val x = buffer.g2Alt1() + val selectedSub = buffer.g2() + return OpObjT( + id, + x, + z, + controlKey, + selectedCombinedId, + selectedSub, + selectedObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer1Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer1Decoder.kt new file mode 100644 index 000000000..140cdc1a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer1Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer1Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER1 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val index = buffer.g2Alt1() + val controlKey = buffer.g1() == 1 + return OpPlayer( + index, + controlKey, + 1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer2Decoder.kt new file mode 100644 index 000000000..2a505199a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer2Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER2 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val index = buffer.g2() + val controlKey = buffer.g1() == 1 + return OpPlayer( + index, + controlKey, + 2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer3Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer3Decoder.kt new file mode 100644 index 000000000..83fb67467 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer3Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer3Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER3 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val index = buffer.g2Alt1() + val controlKey = buffer.g1() == 1 + return OpPlayer( + index, + controlKey, + 3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer4Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer4Decoder.kt new file mode 100644 index 000000000..eb03f548b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer4Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer4Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER4 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val controlKey = buffer.g1Alt2() == 1 + val index = buffer.g2Alt2() + return OpPlayer( + index, + controlKey, + 4, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer5Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer5Decoder.kt new file mode 100644 index 000000000..771970896 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer5Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer5Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER5 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val index = buffer.g2() + val controlKey = buffer.g1Alt2() == 1 + return OpPlayer( + index, + controlKey, + 5, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer6Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer6Decoder.kt new file mode 100644 index 000000000..ec5efad5a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer6Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer6Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER6 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val controlKey = buffer.g1Alt3() == 1 + val index = buffer.g2Alt3() + return OpPlayer( + index, + controlKey, + 6, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer7Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer7Decoder.kt new file mode 100644 index 000000000..0161b5c29 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer7Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer7Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER7 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val controlKey = buffer.g1Alt3() == 1 + val index = buffer.g2Alt3() + return OpPlayer( + index, + controlKey, + 7, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer8Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer8Decoder.kt new file mode 100644 index 000000000..7b8fb65fe --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayer8Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayer +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpPlayer8Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYER8 + + override fun decode(buffer: JagByteBuf): OpPlayer { + val index = buffer.g2() + val controlKey = buffer.g1Alt2() == 1 + return OpPlayer( + index, + controlKey, + 8, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayerTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayerTDecoder.kt new file mode 100644 index 000000000..6e93fdc85 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/players/OpPlayerTDecoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.players + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.players.OpPlayerT +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class OpPlayerTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPPLAYERT + + override fun decode(buffer: JagByteBuf): OpPlayerT { + val controlKey = buffer.g1() == 1 + val selectedSub = buffer.g2Alt2() + val selectedObj = buffer.g2Alt2() + val selectedCombinedId = buffer.gCombinedIdAlt3() + val index = buffer.g2() + return OpPlayerT( + index, + controlKey, + selectedCombinedId, + selectedSub, + selectedObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePCountDialogDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePCountDialogDecoder.kt new file mode 100644 index 000000000..eed9d4a75 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePCountDialogDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.resumed + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.resumed.ResumePCountDialog +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResumePCountDialogDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RESUME_P_COUNTDIALOG + + override fun decode(buffer: JagByteBuf): ResumePCountDialog { + val count = buffer.g4() + return ResumePCountDialog(count) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePNameDialogDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePNameDialogDecoder.kt new file mode 100644 index 000000000..d8e842265 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePNameDialogDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.resumed + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.resumed.ResumePNameDialog +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResumePNameDialogDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RESUME_P_NAMEDIALOG + + override fun decode(buffer: JagByteBuf): ResumePNameDialog { + val name = buffer.gjstr() + return ResumePNameDialog(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePObjDialogDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePObjDialogDecoder.kt new file mode 100644 index 000000000..125a172ea --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePObjDialogDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.resumed + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.resumed.ResumePObjDialog +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResumePObjDialogDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RESUME_P_OBJDIALOG + + override fun decode(buffer: JagByteBuf): ResumePObjDialog { + val obj = buffer.g2() + return ResumePObjDialog(obj) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePStringDialogDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePStringDialogDecoder.kt new file mode 100644 index 000000000..a7f99855c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePStringDialogDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.resumed + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.resumed.ResumePStringDialog +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResumePStringDialogDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RESUME_P_STRINGDIALOG + + override fun decode(buffer: JagByteBuf): ResumePStringDialog { + val string = buffer.gjstr() + return ResumePStringDialog(string) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePauseButtonDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePauseButtonDecoder.kt new file mode 100644 index 000000000..e2950e9ef --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/resumed/ResumePauseButtonDecoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.resumed + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.resumed.ResumePauseButton +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedIdAlt3 + +public class ResumePauseButtonDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.RESUME_PAUSEBUTTON + + override fun decode(buffer: JagByteBuf): ResumePauseButton { + val sub = buffer.g2Alt2() + val combinedId = buffer.gCombinedIdAlt3() + return ResumePauseButton( + combinedId, + sub, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListAddDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListAddDecoder.kt new file mode 100644 index 000000000..735306f85 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListAddDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.social.FriendListAdd +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class FriendListAddDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.FRIENDLIST_ADD + + override fun decode(buffer: JagByteBuf): FriendListAdd { + val name = buffer.gjstr() + return FriendListAdd(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListDelDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListDelDecoder.kt new file mode 100644 index 000000000..d147c7fde --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/FriendListDelDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.social.FriendListDel +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class FriendListDelDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.FRIENDLIST_DEL + + override fun decode(buffer: JagByteBuf): FriendListDel { + val name = buffer.gjstr() + return FriendListDel(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListAddDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListAddDecoder.kt new file mode 100644 index 000000000..e15848430 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListAddDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.social.IgnoreListAdd +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class IgnoreListAddDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IGNORELIST_ADD + + override fun decode(buffer: JagByteBuf): IgnoreListAdd { + val name = buffer.gjstr() + return IgnoreListAdd(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListDelDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListDelDecoder.kt new file mode 100644 index 000000000..4b05c014c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/social/IgnoreListDelDecoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.incoming.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.social.IgnoreListDel +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class IgnoreListDelDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.IGNORELIST_DEL + + override fun decode(buffer: JagByteBuf): IgnoreListDel { + val name = buffer.gjstr() + return IgnoreListDel(name) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity1Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity1Decoder.kt new file mode 100644 index 000000000..91dcde89d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity1Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity1Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY1 + + override fun decode(buffer: JagByteBuf): OpWorldEntity { + val controlKey = buffer.g1() == 1 + val index = buffer.g2Alt1() + return OpWorldEntity( + index, + controlKey, + 1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity2Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity2Decoder.kt new file mode 100644 index 000000000..97bc931ac --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity2Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity2Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY2 + + override fun decode(buffer: JagByteBuf): OpWorldEntity { + val index = buffer.g2Alt1() + val controlKey = buffer.g1Alt3() == 1 + return OpWorldEntity( + index, + controlKey, + 2, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity3Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity3Decoder.kt new file mode 100644 index 000000000..bbfc02f55 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity3Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity3Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY3 + + override fun decode(buffer: JagByteBuf): OpWorldEntity { + val controlKey = buffer.g1Alt1() == 1 + val index = buffer.g2Alt1() + return OpWorldEntity( + index, + controlKey, + 3, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity4Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity4Decoder.kt new file mode 100644 index 000000000..b637bedf9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity4Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity4Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY4 + + override fun decode(buffer: JagByteBuf): OpWorldEntity { + val index = buffer.g2Alt3() + val controlKey = buffer.g1Alt1() == 1 + return OpWorldEntity( + index, + controlKey, + 4, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity5Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity5Decoder.kt new file mode 100644 index 000000000..3fa142979 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity5Decoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity5Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY5 + + override fun decode(buffer: JagByteBuf): OpWorldEntity { + val index = buffer.g2() + val controlKey = buffer.g1Alt1() == 1 + return OpWorldEntity( + index, + controlKey, + 5, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity6Decoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity6Decoder.kt new file mode 100644 index 000000000..5ab91e388 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntity6Decoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntity6 +import net.rsprot.protocol.message.codec.MessageDecoder + +public class OpWorldEntity6Decoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITY6 + + override fun decode(buffer: JagByteBuf): OpWorldEntity6 { + val id = buffer.g2() + return OpWorldEntity6(id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntityTDecoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntityTDecoder.kt new file mode 100644 index 000000000..d611a1017 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/codec/worldentities/OpWorldEntityTDecoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.incoming.codec.worldentities + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.game.incoming.prot.GameClientProt +import net.rsprot.protocol.game.incoming.worldentities.OpWorldEntityT +import net.rsprot.protocol.message.codec.MessageDecoder +import net.rsprot.protocol.util.gCombinedId + +public class OpWorldEntityTDecoder : MessageDecoder { + override val prot: ClientProt = GameClientProt.OPWORLDENTITYT + + override fun decode(buffer: JagByteBuf): OpWorldEntityT { + val selectedSub = buffer.g2() + val selectedCombinedId = buffer.gCombinedId() + val index = buffer.g2Alt3() + val selectedObj = buffer.g2Alt2() + val controlKey = buffer.g1Alt3() == 1 + return OpWorldEntityT( + index, + controlKey, + selectedCombinedId, + selectedSub, + selectedObj, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/DesktopGameMessageDecoderRepository.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/DesktopGameMessageDecoderRepository.kt new file mode 100644 index 000000000..ade347db1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/DesktopGameMessageDecoderRepository.kt @@ -0,0 +1,220 @@ +package net.rsprot.protocol.game.incoming.prot + +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.ProtRepository +import net.rsprot.protocol.game.incoming.codec.buttons.If1ButtonDecoder +import net.rsprot.protocol.game.incoming.codec.buttons.IfButtonDDecoder +import net.rsprot.protocol.game.incoming.codec.buttons.IfButtonTDecoder +import net.rsprot.protocol.game.incoming.codec.buttons.IfButtonXDecoder +import net.rsprot.protocol.game.incoming.codec.buttons.IfRunScriptDecoder +import net.rsprot.protocol.game.incoming.codec.buttons.IfSubOpDecoder +import net.rsprot.protocol.game.incoming.codec.clan.AffinedClanSettingsAddBannedFromChannelDecoder +import net.rsprot.protocol.game.incoming.codec.clan.AffinedClanSettingsSetMutedFromChannelDecoder +import net.rsprot.protocol.game.incoming.codec.clan.ClanChannelFullRequestDecoder +import net.rsprot.protocol.game.incoming.codec.clan.ClanChannelKickUserDecoder +import net.rsprot.protocol.game.incoming.codec.clan.ClanSettingsFullRequestDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventAppletFocusDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventCameraPositionDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventKeyboardDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventMouseClickV1Decoder +import net.rsprot.protocol.game.incoming.codec.events.EventMouseClickV2Decoder +import net.rsprot.protocol.game.incoming.codec.events.EventMouseMoveDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventMouseScrollDecoder +import net.rsprot.protocol.game.incoming.codec.events.EventNativeMouseMoveDecoder +import net.rsprot.protocol.game.incoming.codec.friendchat.FriendChatJoinLeaveDecoder +import net.rsprot.protocol.game.incoming.codec.friendchat.FriendChatKickDecoder +import net.rsprot.protocol.game.incoming.codec.friendchat.FriendChatSetRankDecoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc1Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc2Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc3Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc4Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc5Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLoc6Decoder +import net.rsprot.protocol.game.incoming.codec.locs.OpLocTDecoder +import net.rsprot.protocol.game.incoming.codec.messaging.MessagePrivateDecoder +import net.rsprot.protocol.game.incoming.codec.messaging.MessagePublicDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.ConnectionTelemetryDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.DetectModifiedClientDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.IdleDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.MapBuildCompleteDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.MembershipPromotionEligibilityDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.NoTimeoutDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.RSevenStatusDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.ReflectionCheckReplyDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.SendPingReplyDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.SoundJingleEndDecoder +import net.rsprot.protocol.game.incoming.codec.misc.client.WindowStatusDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.BugReportDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.ClickWorldMapDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.ClientCheatDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.CloseModalDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.HiscoreRequestDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.IfCrmViewClickDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.MoveGameClickDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.MoveMinimapClickDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.OculusLeaveDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.SendSnapshotDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.SetChatFilterSettingsDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.SetHeadingDecoder +import net.rsprot.protocol.game.incoming.codec.misc.user.TeleportDecoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc1Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc2Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc3Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc4Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc5Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpc6Decoder +import net.rsprot.protocol.game.incoming.codec.npcs.OpNpcTDecoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj1Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj2Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj3Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj4Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj5Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObj6Decoder +import net.rsprot.protocol.game.incoming.codec.objs.OpObjTDecoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer1Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer2Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer3Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer4Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer5Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer6Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer7Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayer8Decoder +import net.rsprot.protocol.game.incoming.codec.players.OpPlayerTDecoder +import net.rsprot.protocol.game.incoming.codec.resumed.ResumePCountDialogDecoder +import net.rsprot.protocol.game.incoming.codec.resumed.ResumePNameDialogDecoder +import net.rsprot.protocol.game.incoming.codec.resumed.ResumePObjDialogDecoder +import net.rsprot.protocol.game.incoming.codec.resumed.ResumePStringDialogDecoder +import net.rsprot.protocol.game.incoming.codec.resumed.ResumePauseButtonDecoder +import net.rsprot.protocol.game.incoming.codec.social.FriendListAddDecoder +import net.rsprot.protocol.game.incoming.codec.social.FriendListDelDecoder +import net.rsprot.protocol.game.incoming.codec.social.IgnoreListAddDecoder +import net.rsprot.protocol.game.incoming.codec.social.IgnoreListDelDecoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity1Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity2Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity3Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity4Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity5Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntity6Decoder +import net.rsprot.protocol.game.incoming.codec.worldentities.OpWorldEntityTDecoder +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepositoryBuilder + +public object DesktopGameMessageDecoderRepository { + @ExperimentalStdlibApi + public fun build(huffmanCodecProvider: HuffmanCodecProvider): MessageDecoderRepository { + val protRepository = ProtRepository.of() + val builder = + MessageDecoderRepositoryBuilder( + protRepository, + ).apply { + bind(If1ButtonDecoder()) + bind(IfButtonXDecoder()) + bind(IfSubOpDecoder()) + bind(IfButtonDDecoder()) + bind(IfButtonTDecoder()) + bind(IfRunScriptDecoder()) + + bind(OpNpc1Decoder()) + bind(OpNpc2Decoder()) + bind(OpNpc3Decoder()) + bind(OpNpc4Decoder()) + bind(OpNpc5Decoder()) + bind(OpNpc6Decoder()) + bind(OpNpcTDecoder()) + + bind(OpLoc1Decoder()) + bind(OpLoc2Decoder()) + bind(OpLoc3Decoder()) + bind(OpLoc4Decoder()) + bind(OpLoc5Decoder()) + bind(OpLoc6Decoder()) + bind(OpLocTDecoder()) + + bind(OpObj1Decoder()) + bind(OpObj2Decoder()) + bind(OpObj3Decoder()) + bind(OpObj4Decoder()) + bind(OpObj5Decoder()) + bind(OpObj6Decoder()) + bind(OpObjTDecoder()) + + bind(OpPlayer1Decoder()) + bind(OpPlayer2Decoder()) + bind(OpPlayer3Decoder()) + bind(OpPlayer4Decoder()) + bind(OpPlayer5Decoder()) + bind(OpPlayer6Decoder()) + bind(OpPlayer7Decoder()) + bind(OpPlayer8Decoder()) + bind(OpPlayerTDecoder()) + + bind(OpWorldEntity1Decoder()) + bind(OpWorldEntity2Decoder()) + bind(OpWorldEntity3Decoder()) + bind(OpWorldEntity4Decoder()) + bind(OpWorldEntity5Decoder()) + bind(OpWorldEntity6Decoder()) + bind(OpWorldEntityTDecoder()) + + bind(EventAppletFocusDecoder()) + bind(EventCameraPositionDecoder()) + bind(EventKeyboardDecoder()) + bind(EventMouseScrollDecoder()) + bind(EventMouseMoveDecoder()) + bind(EventNativeMouseMoveDecoder()) + bind(EventMouseClickV1Decoder()) + bind(EventMouseClickV2Decoder()) + + bind(ResumePauseButtonDecoder()) + bind(ResumePNameDialogDecoder()) + bind(ResumePStringDialogDecoder()) + bind(ResumePCountDialogDecoder()) + bind(ResumePObjDialogDecoder()) + + bind(FriendChatKickDecoder()) + bind(FriendChatSetRankDecoder()) + bind(FriendChatJoinLeaveDecoder()) + + bind(ClanChannelFullRequestDecoder()) + bind(ClanSettingsFullRequestDecoder()) + bind(ClanChannelKickUserDecoder()) + bind(AffinedClanSettingsAddBannedFromChannelDecoder()) + bind(AffinedClanSettingsSetMutedFromChannelDecoder()) + + bind(FriendListAddDecoder()) + bind(FriendListDelDecoder()) + bind(IgnoreListAddDecoder()) + bind(IgnoreListDelDecoder()) + + bind(MessagePublicDecoder(huffmanCodecProvider)) + bind(MessagePrivateDecoder(huffmanCodecProvider)) + + bind(MoveGameClickDecoder()) + bind(MoveMinimapClickDecoder()) + bind(ClientCheatDecoder()) + bind(SetChatFilterSettingsDecoder()) + bind(SetHeadingDecoder()) + bind(ClickWorldMapDecoder()) + bind(OculusLeaveDecoder()) + bind(CloseModalDecoder()) + bind(TeleportDecoder()) + bind(BugReportDecoder()) + bind(SendSnapshotDecoder()) + bind(HiscoreRequestDecoder()) + bind(IfCrmViewClickDecoder()) + + bind(ConnectionTelemetryDecoder()) + bind(SendPingReplyDecoder()) + bind(DetectModifiedClientDecoder()) + bind(ReflectionCheckReplyDecoder()) + bind(NoTimeoutDecoder()) + bind(IdleDecoder()) + bind(MapBuildCompleteDecoder()) + bind(MembershipPromotionEligibilityDecoder()) + bind(SoundJingleEndDecoder()) + bind(WindowStatusDecoder()) + bind(RSevenStatusDecoder()) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProt.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProt.kt new file mode 100644 index 000000000..9acd03f8a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProt.kt @@ -0,0 +1,176 @@ +package net.rsprot.protocol.game.incoming.prot + +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.Prot + +public enum class GameClientProt( + override val opcode: Int, + override val size: Int, +) : ClientProt { + // If buttons + IF_BUTTON(GameClientProtId.IF_BUTTON, 4), + IF_BUTTONX(GameClientProtId.IF_BUTTONX, 9), + IF_SUBOP(GameClientProtId.IF_SUBOP, 10), + IF_BUTTOND(GameClientProtId.IF_BUTTOND, 16), + IF_BUTTONT(GameClientProtId.IF_BUTTONT, 16), + IF_RUNSCRIPT(GameClientProtId.IF_RUNSCRIPT, Prot.VAR_SHORT), + + // Op npc + OPNPC1(GameClientProtId.OPNPC1, 3), + OPNPC2(GameClientProtId.OPNPC2, 3), + OPNPC3(GameClientProtId.OPNPC3, 3), + OPNPC4(GameClientProtId.OPNPC4, 3), + OPNPC5(GameClientProtId.OPNPC5, 3), + OPNPC6(GameClientProtId.OPNPC6, 2), + OPNPCT(GameClientProtId.OPNPCT, 11), + + @Deprecated( + "Deprecated since inventory rework in revision 204, " + + "all usages go through OPNPCT now.", + replaceWith = ReplaceWith("OPNPCT"), + ) + OPNPCU(GameClientProtId.OPNPCU, 11), + + // Op loc + OPLOC1(GameClientProtId.OPLOC1, 7), + OPLOC2(GameClientProtId.OPLOC2, 7), + OPLOC3(GameClientProtId.OPLOC3, 7), + OPLOC4(GameClientProtId.OPLOC4, 7), + OPLOC5(GameClientProtId.OPLOC5, 7), + OPLOC6(GameClientProtId.OPLOC6, 2), + OPLOCT(GameClientProtId.OPLOCT, 15), + + @Deprecated( + "Deprecated since inventory rework in revision 204, " + + "all usages go through OPLOCT now.", + replaceWith = ReplaceWith("OPLOCT"), + ) + OPLOCU(GameClientProtId.OPLOCU, 15), + + // Op obj + OPOBJ1(GameClientProtId.OPOBJ1, 7), + OPOBJ2(GameClientProtId.OPOBJ2, 7), + OPOBJ3(GameClientProtId.OPOBJ3, 7), + OPOBJ4(GameClientProtId.OPOBJ4, 7), + OPOBJ5(GameClientProtId.OPOBJ5, 7), + OPOBJ6(GameClientProtId.OPOBJ6, 6), + OPOBJT(GameClientProtId.OPOBJT, 15), + + @Deprecated( + "Deprecated since inventory rework in revision 204, " + + "all usages go through OPOBJT now.", + replaceWith = ReplaceWith("OPOBJT"), + ) + OPOBJU(GameClientProtId.OPOBJU, 15), + + // Op player + OPPLAYER1(GameClientProtId.OPPLAYER1, 3), + OPPLAYER2(GameClientProtId.OPPLAYER2, 3), + OPPLAYER3(GameClientProtId.OPPLAYER3, 3), + OPPLAYER4(GameClientProtId.OPPLAYER4, 3), + OPPLAYER5(GameClientProtId.OPPLAYER5, 3), + OPPLAYER6(GameClientProtId.OPPLAYER6, 3), + OPPLAYER7(GameClientProtId.OPPLAYER7, 3), + OPPLAYER8(GameClientProtId.OPPLAYER8, 3), + OPPLAYERT(GameClientProtId.OPPLAYERT, 11), + + @Deprecated( + "Deprecated since inventory rework in revision 204, " + + "all usages go through OPPLAYERT now.", + replaceWith = ReplaceWith("OPPLAYERT"), + ) + OPPLAYERU(GameClientProtId.OPPLAYERU, 11), + + OPWORLDENTITY1(GameClientProtId.OPWORLDENTITY1, 3), + OPWORLDENTITY2(GameClientProtId.OPWORLDENTITY2, 3), + OPWORLDENTITY3(GameClientProtId.OPWORLDENTITY3, 3), + OPWORLDENTITY4(GameClientProtId.OPWORLDENTITY4, 3), + OPWORLDENTITY5(GameClientProtId.OPWORLDENTITY5, 3), + OPWORLDENTITY6(GameClientProtId.OPWORLDENTITY6, 2), + OPWORLDENTITYT(GameClientProtId.OPWORLDENTITYT, 11), + + @Deprecated( + "Deprecated since inventory rework in revision 204, " + + "all usages go through OPWORLDENTITYT now.", + replaceWith = ReplaceWith("OPWORLDENTITYT"), + ) + OPWORLDENTITYU(GameClientProtId.OPWORLDENTITYU, 11), + + // Events + EVENT_APPLET_FOCUS(GameClientProtId.EVENT_APPLET_FOCUS, 1), + EVENT_CAMERA_POSITION(GameClientProtId.EVENT_CAMERA_POSITION, 4), + EVENT_KEYBOARD(GameClientProtId.EVENT_KEYBOARD, Prot.VAR_SHORT), + EVENT_MOUSE_SCROLL(GameClientProtId.EVENT_MOUSE_SCROLL, 2), + EVENT_MOUSE_MOVE(GameClientProtId.EVENT_MOUSE_MOVE, Prot.VAR_BYTE), + EVENT_NATIVE_MOUSE_MOVE(GameClientProtId.EVENT_NATIVE_MOUSE_MOVE, Prot.VAR_BYTE), + EVENT_MOUSE_CLICK_V1(GameClientProtId.EVENT_MOUSE_CLICK_V1, 6), + EVENT_MOUSE_CLICK_V2(GameClientProtId.EVENT_NATIVE_MOUSE_CLICK_V2, 7), + + @Deprecated( + "Deprecated since rework in revision 232, " + + "where the platforms were joined into a single packet.", + replaceWith = ReplaceWith("EVENT_MOUSE_CLICK_V2"), + ) + EVENT_NATIVE_MOUSE_CLICK(GameClientProtId.EVENT_NATIVE_MOUSE_CLICK_V1, 7), + + // Resume events + RESUME_PAUSEBUTTON(GameClientProtId.RESUME_PAUSEBUTTON, 6), + RESUME_P_NAMEDIALOG(GameClientProtId.RESUME_P_NAMEDIALOG, Prot.VAR_BYTE), + RESUME_P_STRINGDIALOG(GameClientProtId.RESUME_P_STRINGDIALOG, Prot.VAR_BYTE), + RESUME_P_COUNTDIALOG(GameClientProtId.RESUME_P_COUNTDIALOG, 4), + RESUME_P_OBJDIALOG(GameClientProtId.RESUME_P_OBJDIALOG, 2), + + // Friend chat packets + FRIENDCHAT_KICK(GameClientProtId.FRIENDCHAT_KICK, Prot.VAR_BYTE), + FRIENDCHAT_SETRANK(GameClientProtId.FRIENDCHAT_SETRANK, Prot.VAR_BYTE), + FRIENDCHAT_JOIN_LEAVE(GameClientProtId.FRIENDCHAT_JOIN_LEAVE, Prot.VAR_BYTE), + + // Clan packets + CLANCHANNEL_FULL_REQUEST(GameClientProtId.CLANCHANNEL_FULL_REQUEST, 1), + CLANSETTINGS_FULL_REQUEST(GameClientProtId.CLANSETTINGS_FULL_REQUEST, 1), + CLANCHANNEL_KICKUSER(GameClientProtId.CLANCHANNEL_KICKUSER, Prot.VAR_BYTE), + AFFINEDCLANSETTINGS_ADDBANNED_FROMCHANNEL( + GameClientProtId.AFFINEDCLANSETTINGS_ADDBANNED_FROMCHANNEL, + Prot.VAR_BYTE, + ), + AFFINEDCLANSETTINGS_SETMUTED_FROMCHANNEL(GameClientProtId.AFFINEDCLANSETTINGS_SETMUTED_FROMCHANNEL, Prot.VAR_BYTE), + + // Socials + FRIENDLIST_ADD(GameClientProtId.FRIENDLIST_ADD, Prot.VAR_BYTE), + FRIENDLIST_DEL(GameClientProtId.FRIENDLIST_DEL, Prot.VAR_BYTE), + IGNORELIST_ADD(GameClientProtId.IGNORELIST_ADD, Prot.VAR_BYTE), + IGNORELIST_DEL(GameClientProtId.IGNORELIST_DEL, Prot.VAR_BYTE), + + // Messaging + MESSAGE_PUBLIC(GameClientProtId.MESSAGE_PUBLIC, Prot.VAR_BYTE), + MESSAGE_PRIVATE(GameClientProtId.MESSAGE_PRIVATE, Prot.VAR_SHORT), + + // Misc. user packets + MOVE_GAMECLICK(GameClientProtId.MOVE_GAMECLICK, Prot.VAR_BYTE), + MOVE_MINIMAPCLICK(GameClientProtId.MOVE_MINIMAPCLICK, Prot.VAR_BYTE), + CLIENT_CHEAT(GameClientProtId.CLIENT_CHEAT, Prot.VAR_BYTE), + SET_CHATFILTERSETTINGS(GameClientProtId.SET_CHATFILTERSETTINGS, 3), + CLICKWORLDMAP(GameClientProtId.CLICKWORLDMAP, 4), + OCULUS_LEAVE(GameClientProtId.OCULUS_LEAVE, 0), + CLOSE_MODAL(GameClientProtId.CLOSE_MODAL, 0), + TELEPORT(GameClientProtId.TELEPORT, 9), + BUG_REPORT(GameClientProtId.BUG_REPORT, Prot.VAR_SHORT), + SEND_SNAPSHOT(GameClientProtId.SEND_SNAPSHOT, Prot.VAR_BYTE), + HISCORE_REQUEST(GameClientProtId.HISCORE_REQUEST, Prot.VAR_BYTE), + IF_CRMVIEW_CLICK(GameClientProtId.IF_CRMVIEW_CLICK, 22), + UPDATE_PLAYER_MODEL_V2(GameClientProtId.UPDATE_PLAYER_MODEL_V2, 26), + + // Misc. client packets + CONNECTION_TELEMETRY(GameClientProtId.CONNECTION_TELEMETRY, Prot.VAR_BYTE), + SEND_PING_REPLY(GameClientProtId.SEND_PING_REPLY, 10), + DETECT_MODIFIED_CLIENT(GameClientProtId.DETECT_MODIFIED_CLIENT, 4), + REFLECTION_CHECK_REPLY(GameClientProtId.REFLECTION_CHECK_REPLY, Prot.VAR_BYTE), + NO_TIMEOUT(GameClientProtId.NO_TIMEOUT, 0), + IDLE(GameClientProtId.IDLE, 0), + MAP_BUILD_COMPLETE(GameClientProtId.MAP_BUILD_COMPLETE, 0), + MEMBERSHIP_PROMOTION_ELIGIBILITY(GameClientProtId.MEMBERSHIP_PROMOTION_ELIGIBILITY, 2), + SOUND_JINGLEEND(GameClientProtId.SOUND_JINGLEEND, 4), + WINDOW_STATUS(GameClientProtId.WINDOW_STATUS, 5), + SET_HEADING(GameClientProtId.SET_HEADING, 1), + RSEVEN_STATUS(GameClientProtId.RSEVEN_STATUS, 1), +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProtId.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProtId.kt new file mode 100644 index 000000000..d047e5fd3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/incoming/prot/GameClientProtId.kt @@ -0,0 +1,105 @@ +package net.rsprot.protocol.game.incoming.prot + +internal object GameClientProtId { + const val OPWORLDENTITYT = 0 + const val IGNORELIST_ADD = 1 + const val AFFINEDCLANSETTINGS_ADDBANNED_FROMCHANNEL = 2 + const val OPWORLDENTITY3 = 3 + const val CLANSETTINGS_FULL_REQUEST = 4 + const val WINDOW_STATUS = 5 + const val UPDATE_PLAYER_MODEL_V2 = 6 + const val SEND_PING_REPLY = 7 + const val REFLECTION_CHECK_REPLY = 8 + const val EVENT_NATIVE_MOUSE_MOVE = 9 + const val SEND_SNAPSHOT = 10 + const val OPPLAYER8 = 11 + const val HISCORE_REQUEST = 12 + const val CLANCHANNEL_KICKUSER = 13 + const val FRIENDCHAT_KICK = 14 + const val EVENT_APPLET_FOCUS = 15 + const val OPLOCU = 16 + const val RESUME_P_OBJDIALOG = 17 + const val EVENT_MOUSE_SCROLL = 18 + const val EVENT_MOUSE_CLICK_V1 = 19 + const val IF_RUNSCRIPT = 20 + const val OPOBJU = 21 + const val OPNPC1 = 22 + const val OPLOC1 = 23 + const val MESSAGE_PRIVATE = 24 + const val OPLOC2 = 25 + const val OPOBJT = 26 + const val OPPLAYER4 = 27 + const val RESUME_PAUSEBUTTON = 28 + const val FRIENDCHAT_SETRANK = 29 + const val OPLOC3 = 30 + const val BUG_REPORT = 31 + const val OPWORLDENTITY1 = 32 + const val AFFINEDCLANSETTINGS_SETMUTED_FROMCHANNEL = 33 + const val FRIENDLIST_DEL = 34 + const val RESUME_P_NAMEDIALOG = 35 + const val OPPLAYER6 = 36 + const val RESUME_P_STRINGDIALOG = 37 + const val DETECT_MODIFIED_CLIENT = 38 + const val OPLOC4 = 39 + const val OPOBJ5 = 40 + const val OPNPCU = 41 + const val EVENT_MOUSE_MOVE = 42 + const val CLOSE_MODAL = 43 + const val OPOBJ3 = 44 + const val OPOBJ1 = 45 + const val RESUME_P_COUNTDIALOG = 46 + const val OCULUS_LEAVE = 47 + const val IGNORELIST_DEL = 48 + const val OPWORLDENTITY5 = 49 + const val OPLOCT = 50 + const val TELEPORT = 51 + const val OPWORLDENTITYU = 52 + const val OPNPCT = 53 + const val MOVE_GAMECLICK = 54 + const val IF_BUTTONT = 55 + const val OPLOC6 = 56 + const val IF_BUTTON = 57 + const val OPPLAYERU = 58 + const val MAP_BUILD_COMPLETE = 59 + const val IF_BUTTOND = 60 + const val MOVE_MINIMAPCLICK = 61 + const val CONNECTION_TELEMETRY = 62 + const val OPNPC3 = 63 + const val OPPLAYER5 = 64 + const val OPOBJ2 = 65 + const val OPPLAYER2 = 66 + const val OPNPC4 = 67 + const val CLIENT_CHEAT = 68 + const val FRIENDLIST_ADD = 69 + const val OPNPC2 = 70 + const val OPPLAYER1 = 71 + const val CLANCHANNEL_FULL_REQUEST = 72 + const val OPNPC5 = 73 + const val IDLE = 74 + const val OPPLAYER3 = 75 + const val CLICKWORLDMAP = 76 + const val EVENT_NATIVE_MOUSE_CLICK_V2 = 77 + const val EVENT_KEYBOARD = 78 + const val OPPLAYERT = 79 + const val IF_CRMVIEW_CLICK = 80 + const val SET_HEADING = 81 + const val SOUND_JINGLEEND = 82 + const val OPPLAYER7 = 83 + const val OPWORLDENTITY6 = 84 + const val OPLOC5 = 85 + const val MEMBERSHIP_PROMOTION_ELIGIBILITY = 86 + const val EVENT_CAMERA_POSITION = 87 + const val OPOBJ4 = 88 + const val RSEVEN_STATUS = 89 + const val OPOBJ6 = 90 + const val IF_BUTTONX = 91 + const val OPWORLDENTITY2 = 92 + const val OPNPC6 = 93 + const val MESSAGE_PUBLIC = 94 + const val IF_SUBOP = 95 + const val OPWORLDENTITY4 = 96 + const val EVENT_NATIVE_MOUSE_CLICK_V1 = 97 + const val SET_CHATFILTERSETTINGS = 98 + const val NO_TIMEOUT = 99 + const val FRIENDCHAT_JOIN_LEAVE = 100 +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV1Encoder.kt new file mode 100644 index 000000000..721281fae --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV1Encoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamLookAtEasedCoordV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamLookAtEasedCoordV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_LOOKAT_EASED_COORD_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamLookAtEasedCoordV1, + ) { + buffer.p1(message.destinationXInBuildArea) + buffer.p1(message.destinationZInBuildArea) + buffer.p2(message.height) + buffer.p2(message.cycles) + buffer.p1(message.easing.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV2Encoder.kt new file mode 100644 index 000000000..60c84db13 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtEasedCoordV2Encoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamLookAtEasedCoordV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class CamLookAtEasedCoordV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_LOOKAT_EASED_COORD_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamLookAtEasedCoordV2, + ) { + buffer.p2Alt1(message.height) + buffer.p2Alt3(message.z) + buffer.p2Alt2(message.x) + buffer.p1Alt3(message.easing.id) + buffer.p2(message.cycles) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV1Encoder.kt new file mode 100644 index 000000000..a216985d0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV1Encoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamLookAtV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamLookAtV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_LOOKAT_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamLookAtV1, + ) { + buffer.p1(message.destinationXInBuildArea) + buffer.p1(message.destinationZInBuildArea) + buffer.p2(message.height) + buffer.p1(message.rate) + buffer.p1(message.rate2) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV2Encoder.kt new file mode 100644 index 000000000..1d7379813 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamLookAtV2Encoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamLookAtV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class CamLookAtV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_LOOKAT_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamLookAtV2, + ) { + buffer.p2Alt2(message.z) + buffer.p1Alt3(message.rate2) + buffer.p1(message.rate) + buffer.p2(message.x) + buffer.p2(message.height) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamModeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamModeEncoder.kt new file mode 100644 index 000000000..914d02392 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamModeEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMode +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamModeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MODE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMode, + ) { + buffer.p1(message.mode) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV1Encoder.kt new file mode 100644 index 000000000..2f01cfb1f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV1Encoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToArcV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamMoveToArcV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_ARC_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToArcV1, + ) { + buffer.p1(message.destinationXInBuildArea) + buffer.p1(message.destinationZInBuildArea) + buffer.p2(message.height) + buffer.p1(message.centerXInBuildArea) + buffer.p1(message.centerZInBuildArea) + buffer.p2(message.cycles) + buffer.pboolean(message.ignoreTerrain) + buffer.p1(message.easing.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV2Encoder.kt new file mode 100644 index 000000000..adef06fde --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToArcV2Encoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToArcV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class CamMoveToArcV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_ARC_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToArcV2, + ) { + buffer.p1Alt1(if (message.ignoreTerrain) 1 else 0) + buffer.p1(message.easing.id) + buffer.p2(message.height) + buffer.p2Alt3(message.centerZ) + buffer.p2(message.destinationZ) + buffer.p2Alt1(message.cycles) + buffer.p2Alt2(message.centerX) + buffer.p2(message.destinationX) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV1Encoder.kt new file mode 100644 index 000000000..fa2d99d94 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV1Encoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToCyclesV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamMoveToCyclesV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_CYCLES_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToCyclesV1, + ) { + buffer.p1(message.destinationXInBuildArea) + buffer.p1(message.destinationZInBuildArea) + buffer.p2(message.height) + buffer.p2(message.cycles) + buffer.pboolean(message.ignoreTerrain) + buffer.p1(message.easing.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV2Encoder.kt new file mode 100644 index 000000000..fa54575cc --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToCyclesV2Encoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToCyclesV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class CamMoveToCyclesV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_CYCLES_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToCyclesV2, + ) { + buffer.p2Alt1(message.x) + buffer.p1(if (message.ignoreTerrain) 1 else 0) + buffer.p1(message.easing.id) + buffer.p2Alt1(message.cycles) + buffer.p2Alt3(message.z) + buffer.p2Alt2(message.height) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV1Encoder.kt new file mode 100644 index 000000000..802e0f67e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV1Encoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamMoveToV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToV1, + ) { + buffer.p1(message.destinationXInBuildArea) + buffer.p1(message.destinationZInBuildArea) + buffer.p2(message.height) + buffer.p1(message.rate) + buffer.p1(message.rate2) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV2Encoder.kt new file mode 100644 index 000000000..ffe3b595a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamMoveToV2Encoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamMoveToV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class CamMoveToV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_MOVETO_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamMoveToV2, + ) { + buffer.p2Alt3(message.z) + buffer.p2(message.x) + buffer.p2Alt1(message.height) + buffer.p1(message.rate2) + buffer.p1Alt1(message.rate) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamResetEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamResetEncoder.kt new file mode 100644 index 000000000..e16374e3d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamResetEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamReset +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamResetEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_RESET +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateByEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateByEncoder.kt new file mode 100644 index 000000000..99d8ca046 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateByEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamRotateBy +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamRotateByEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_ROTATEBY + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamRotateBy, + ) { + buffer.p2(message.yaw) + buffer.p2(message.pitch) + buffer.p2(message.cycles) + buffer.p1(message.easing.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateToEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateToEncoder.kt new file mode 100644 index 000000000..b3b52cc15 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamRotateToEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamRotateTo +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamRotateToEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_ROTATETO + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamRotateTo, + ) { + buffer.p2(message.yaw) + buffer.p2(message.pitch) + buffer.p2(message.cycles) + buffer.p1(message.easing.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamShakeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamShakeEncoder.kt new file mode 100644 index 000000000..6b451926b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamShakeEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamShake +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamShakeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_SHAKE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamShake, + ) { + buffer.p1(message.axis) + buffer.p1(message.random) + buffer.p1(message.amplitude) + buffer.p1(message.rate) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamSmoothResetEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamSmoothResetEncoder.kt new file mode 100644 index 000000000..74d9d63c3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamSmoothResetEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamSmoothReset +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamSmoothResetEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_SMOOTHRESET + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamSmoothReset, + ) { + buffer.p1(message.cameraMoveConstantSpeed) + buffer.p1(message.cameraMoveProportionalSpeed) + buffer.p1(message.cameraLookConstantSpeed) + buffer.p1(message.cameraLookProportionalSpeed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamTargetV3Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamTargetV3Encoder.kt new file mode 100644 index 000000000..dd85a5d8f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/CamTargetV3Encoder.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.CamTargetV3 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class CamTargetV3Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CAM_TARGET_V3 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: CamTargetV3, + ) { + when (val type = message.type) { + is CamTargetV3.PlayerCamTarget -> { + buffer.p1(0) + buffer.p2(type.worldEntityIndex) + buffer.p2(type.targetIndex) + } + is CamTargetV3.NpcCamTarget -> { + buffer.p1(1) + buffer.p2(type.worldEntityIndex) + buffer.p2(type.targetIndex) + } + is CamTargetV3.WorldEntityTarget -> { + buffer.p1(2) + buffer.p2(-1) + buffer.p2(type.targetIndex) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/OculusSyncEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/OculusSyncEncoder.kt new file mode 100644 index 000000000..4a8bff0c7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/camera/OculusSyncEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.camera + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.camera.OculusSync +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class OculusSyncEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OCULUS_SYNC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: OculusSync, + ) { + buffer.p4(message.value) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelDeltaEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelDeltaEncoder.kt new file mode 100644 index 000000000..b523f94f5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelDeltaEncoder.kt @@ -0,0 +1,87 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.ClanChannelDelta +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanChannelDeltaEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CLANCHANNEL_DELTA + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ClanChannelDelta, + ) { + buffer.p1(message.clanType) + buffer.p8(message.clanHash) + buffer.p8(message.updateNum) + for (event in message.events) { + when (event) { + is ClanChannelDelta.AddUserEvent -> { + buffer.p1(1) + buffer.p1(255) + buffer.pjstrnull(event.name) + buffer.p2(event.world) + buffer.p1(event.rank) + + // Unused in all clients, including RS3 + buffer.p8(0) + } + is ClanChannelDelta.DeleteUserEvent -> { + buffer.p1(3) + buffer.p2(event.index) + + // Unused in all clients, including RS3 + buffer.p1(0) + buffer.p1(255) + } + is ClanChannelDelta.UpdateBaseSettingsEvent -> { + buffer.p1(4) + val name = event.clanName + buffer.pjstrnull(name) + if (name != null) { + // Unused in all clients, including RS3 + buffer.p1(0) + + buffer.p1(event.talkRank) + buffer.p1(event.kickRank) + } + } + is ClanChannelDelta.UpdateUserDetailsEvent -> { + buffer.p1(2) + buffer.p2(event.index) + buffer.p1(event.rank) + buffer.p2(event.world) + + // Unused in all clients, including RS3 + buffer.p8(0) + + buffer.pjstr(event.name) + } + is ClanChannelDelta.UpdateUserDetailsV2Event -> { + buffer.p1(5) + + // Unused in all clients, including RS3 + buffer.p1(0) + buffer.p2(event.index) + buffer.p1(event.rank) + buffer.p2(event.world) + + // Unused in all clients, including RS3 + buffer.p8(0) + + buffer.pjstr(event.name) + + // Unused in all clients, including RS3 + buffer.p1(0) + } + } + } + buffer.p1(0) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelFullEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelFullEncoder.kt new file mode 100644 index 000000000..176818ce1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanChannelFullEncoder.kt @@ -0,0 +1,58 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.Base37 +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.ClanChannelFull +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanChannelFullEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CLANCHANNEL_FULL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ClanChannelFull, + ) { + buffer.p1(message.clanType) + when (val update = message.update) { + is ClanChannelFull.JoinUpdate -> { + buffer.p1(update.flags) + val version = update.version + if (update.flags and ClanChannelFull.FLAG_HAS_VERSION != 0) { + buffer.p1(version) + } + buffer.p8(update.clanHash) + buffer.p8(update.updateNum) + buffer.pjstr(update.clanName) + buffer.pboolean(update.discardedBoolean) + buffer.p1(update.kickRank) + buffer.p1(update.talkRank) + val members = update.members + buffer.p2(members.size) + val base37 = update.flags and ClanChannelFull.FLAG_USE_BASE_37_NAMES != 0 + val displayNames = update.flags and ClanChannelFull.FLAG_USE_DISPLAY_NAMES != 0 + for (member in members) { + if (base37) { + buffer.p8(Base37.encode(member.name)) + } + if (displayNames) { + buffer.pjstr(member.name) + } + buffer.p1(member.rank) + buffer.p2(member.world) + if (version >= 3) { + buffer.pboolean(member.discardedBoolean) + } + } + } + ClanChannelFull.LeaveUpdate -> { + // No-op + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsDeltaEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsDeltaEncoder.kt new file mode 100644 index 000000000..a057334c7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsDeltaEncoder.kt @@ -0,0 +1,127 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.ClanSettingsDelta +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanSettingsDeltaEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CLANSETTINGS_DELTA + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ClanSettingsDelta, + ) { + buffer.p1(message.clanType) + buffer.p8(message.owner) + buffer.p4(message.updateNum) + val updates = message.updates + for (update in updates) { + when (update) { + is ClanSettingsDelta.SetClanOwnerUpdate -> { + buffer.p1(15) + buffer.p2(update.index) + } + is ClanSettingsDelta.AddBannedUpdate -> { + buffer.p1(3) + val hash = update.hash + if (hash and 0xFF != 0xFF.toLong()) { + buffer.p8(hash) + } else { + buffer.p1(0xFF) + } + buffer.pjstrnull(update.name) + } + is ClanSettingsDelta.AddMemberV1Update -> { + buffer.p1(1) + val hash = update.hash + if (hash and 0xFF != 0xFF.toLong()) { + buffer.p8(hash) + } else { + buffer.p1(0xFF) + } + buffer.pjstrnull(update.name) + } + is ClanSettingsDelta.AddMemberV2Update -> { + buffer.p1(13) + val hash = update.hash + if (hash and 0xFF != 0xFF.toLong()) { + buffer.p8(hash) + } else { + buffer.p1(0xFF) + } + buffer.pjstrnull(update.name) + buffer.p2(update.joinRuneDay) + } + is ClanSettingsDelta.BaseSettingsUpdate -> { + buffer.p1(4) + buffer.p1(if (update.allowUnaffined) 1 else 0) + buffer.p1(update.talkRank) + buffer.p1(update.kickRank) + buffer.p1(update.lootshareRank) + buffer.p1(update.coinshareRank) + } + is ClanSettingsDelta.DeleteBannedUpdate -> { + buffer.p1(6) + buffer.p2(update.index) + } + is ClanSettingsDelta.DeleteMemberUpdate -> { + buffer.p1(5) + buffer.p2(update.index) + } + is ClanSettingsDelta.SetClanNameUpdate -> { + buffer.p1(12) + buffer.pjstr(update.clanName) + + // Unused in all clients, including RS3 + buffer.p4(0) + } + is ClanSettingsDelta.SetIntSettingUpdate -> { + buffer.p1(8) + buffer.p4(update.setting) + buffer.p4(update.value) + } + is ClanSettingsDelta.SetLongSettingUpdate -> { + buffer.p1(9) + buffer.p4(update.setting) + buffer.p8(update.value) + } + is ClanSettingsDelta.SetMemberExtraInfoUpdate -> { + buffer.p1(7) + buffer.p2(update.index) + buffer.p4(update.value) + buffer.p1(update.startBit) + buffer.p1(update.endBit) + } + is ClanSettingsDelta.SetMemberMutedUpdate -> { + buffer.p1(14) + buffer.p2(update.index) + buffer.p1(if (update.muted) 1 else 0) + } + is ClanSettingsDelta.SetMemberRankUpdate -> { + buffer.p1(2) + buffer.p2(update.index) + buffer.p1(update.rank) + } + is ClanSettingsDelta.SetStringSettingUpdate -> { + buffer.p1(10) + buffer.p4(update.setting) + buffer.pjstr(update.value) + } + is ClanSettingsDelta.SetVarbitSettingUpdate -> { + buffer.p1(11) + buffer.p4(update.setting) + buffer.p4(update.value) + buffer.p1(update.startBit) + buffer.p1(update.endBit) + } + } + } + buffer.p1(0) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsFullEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsFullEncoder.kt new file mode 100644 index 000000000..d11335ded --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/ClanSettingsFullEncoder.kt @@ -0,0 +1,85 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.ClanSettingsFull +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ClanSettingsFullEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CLANSETTINGS_FULL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ClanSettingsFull, + ) { + buffer.p1(message.clanType) + val update = message.update + when (update) { + is ClanSettingsFull.JoinUpdate -> { + // Send version always as 6, as it contains the most information + buffer.p1(6) + buffer.p1(update.flags) + buffer.p4(update.updateNum) + buffer.p4(update.creationTime) + buffer.p2(update.affinedMembers.size) + buffer.p1(update.bannedMembers.size) + buffer.pjstr(update.clanName) + + // Unused in all clients, including RS3 + buffer.p4(0) + buffer.p1(if (update.allowUnaffined) 1 else 0) + buffer.p1(update.talkRank) + buffer.p1(update.kickRank) + buffer.p1(update.lootshareRank) + buffer.p1(update.coinshareRank) + val hasAffinedHashes = update.flags and ClanSettingsFull.FLAG_HAS_AFFINED_HASHES != 0 + val hasAffinedDisplayNames = update.flags and ClanSettingsFull.FLAG_HAS_AFFINED_DISPLAY_NAMES != 0 + for (affined in update.affinedMembers) { + if (hasAffinedHashes) { + buffer.p8(affined.hash) + } + if (hasAffinedDisplayNames) { + buffer.pjstrnull(affined.name) + } + buffer.p1(affined.rank) + buffer.p4(affined.extraInfo) + buffer.p2(affined.joinRuneDay) + buffer.p1(if (affined.muted) 1 else 0) + } + for (banned in update.bannedMembers) { + if (hasAffinedHashes) { + buffer.p8(banned.hash) + } + if (hasAffinedDisplayNames) { + buffer.pjstrnull(banned.name) + } + } + buffer.p2(update.settings.size) + for (setting in update.settings) { + when (setting) { + is ClanSettingsFull.IntClanSetting -> { + buffer.p4(setting.id) + buffer.p4(setting.value) + } + is ClanSettingsFull.LongClanSetting -> { + buffer.p4(setting.id or (1 shl 30)) + buffer.p8(setting.value) + } + is ClanSettingsFull.StringClanSetting -> { + buffer.p4(setting.id or (2 shl 30)) + buffer.pjstr(setting.value) + } + } + } + } + ClanSettingsFull.LeaveUpdate -> { + // No-op + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelEncoder.kt new file mode 100644 index 000000000..5fe3fac05 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelEncoder.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.MessageClanChannel +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessageClanChannelEncoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_CLANCHANNEL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessageClanChannel, + ) { + buffer.p1(message.clanType) + buffer.pjstr(message.name) + buffer.p2(message.worldId) + buffer.p3(message.worldMessageCounter) + buffer.p1(message.chatCrownType) + val huffman = huffmanCodecProvider.provide() + huffman.encode(buffer, message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelSystemEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelSystemEncoder.kt new file mode 100644 index 000000000..9b7852a57 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/MessageClanChannelSystemEncoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.MessageClanChannelSystem +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessageClanChannelSystemEncoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_CLANCHANNEL_SYSTEM + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessageClanChannelSystem, + ) { + buffer.p1(message.clanType) + buffer.p2(message.worldId) + buffer.p3(message.worldMessageCounter) + val huffman = huffmanCodecProvider.provide() + huffman.encode(buffer, message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanDisableEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanDisableEncoder.kt new file mode 100644 index 000000000..d34e49421 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanDisableEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.VarClanDisable +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class VarClanDisableEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.VARCLAN_DISABLE +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEnableEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEnableEncoder.kt new file mode 100644 index 000000000..f46165865 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEnableEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.VarClanEnable +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class VarClanEnableEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.VARCLAN_ENABLE +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEncoder.kt new file mode 100644 index 000000000..2418c28cd --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/clan/VarClanEncoder.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.codec.clan + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.clan.VarClan +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class VarClanEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.VARCLAN + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: VarClan, + ) { + buffer.p2(message.id) + // Note that there is another clause for 'serializable' types, + // however none currently exist. + when (val value = message.value) { + is VarClan.VarClanIntData -> { + buffer.p4(value.value) + } + is VarClan.VarClanLongData -> { + buffer.p8(value.value) + } + is VarClan.VarClanStringData -> { + buffer.pjstr2(value.value) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/MessageFriendChannelEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/MessageFriendChannelEncoder.kt new file mode 100644 index 000000000..8864c589c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/MessageFriendChannelEncoder.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.outgoing.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.friendchat.MessageFriendChannel +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessageFriendChannelEncoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_FRIENDCHANNEL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessageFriendChannel, + ) { + buffer.pjstr(message.sender) + buffer.p8(message.channelNameBase37) + buffer.p2(message.worldId) + buffer.p3(message.worldMessageCounter) + buffer.p1(message.chatCrownType) + val huffman = huffmanCodecProvider.provide() + huffman.encode(buffer, message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelFullV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelFullV2Encoder.kt new file mode 100644 index 000000000..450412663 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelFullV2Encoder.kt @@ -0,0 +1,42 @@ +package net.rsprot.protocol.game.outgoing.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.friendchat.UpdateFriendChatChannelFullV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateFriendChatChannelFullV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_FRIENDCHAT_CHANNEL_FULL_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateFriendChatChannelFullV2, + ) { + when (val update = message.updateType) { + is UpdateFriendChatChannelFullV2.JoinUpdate -> { + buffer.pjstr(update.channelOwner) + buffer.p8(update.channelNameBase37) + buffer.p1(update.kickRank) + if (update.entries.isEmpty()) { + buffer.pSmart1or2null(-1) + } else { + buffer.pSmart1or2null(update.entries.size) + for (entry in update.entries) { + buffer.pjstr(entry.name) + buffer.p2(entry.worldId) + buffer.p1(entry.rank) + buffer.pjstr(entry.worldName) + } + } + } + UpdateFriendChatChannelFullV2.LeaveUpdate -> { + // No-op, empty packet + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelSingleUserEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelSingleUserEncoder.kt new file mode 100644 index 000000000..31cdd32a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/friendchat/UpdateFriendChatChannelSingleUserEncoder.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.game.outgoing.codec.friendchat + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.friendchat.UpdateFriendChatChannelSingleUser +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateFriendChatChannelSingleUserEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_FRIENDCHAT_CHANNEL_SINGLEUSER + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateFriendChatChannelSingleUser, + ) { + val user = message.user + buffer.pjstr(user.name) + buffer.p2(user.worldId) + buffer.p1(user.rank) + if (user is UpdateFriendChatChannelSingleUser.AddedFriendChatUser) { + buffer.pjstr(user.worldName) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfClearInvEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfClearInvEncoder.kt new file mode 100644 index 000000000..ab45ec51d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfClearInvEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfClearInv +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfClearInvEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_CLEARINV + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfClearInv, + ) { + buffer.pCombinedIdAlt3(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfCloseSubEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfCloseSubEncoder.kt new file mode 100644 index 000000000..4967ad624 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfCloseSubEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfCloseSub +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent +import net.rsprot.protocol.util.pCombinedId + +@Consistent +public class IfCloseSubEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_CLOSESUB + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfCloseSub, + ) { + buffer.pCombinedId(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfMoveSubEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfMoveSubEncoder.kt new file mode 100644 index 000000000..f003a7cde --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfMoveSubEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfMoveSub +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfMoveSubEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_MOVESUB + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfMoveSub, + ) { + buffer.pCombinedIdAlt3(message.destinationCombinedId) + buffer.pCombinedIdAlt2(message.sourceCombinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenSubEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenSubEncoder.kt new file mode 100644 index 000000000..403699f22 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenSubEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfOpenSub +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfOpenSubEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_OPENSUB + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfOpenSub, + ) { + buffer.pCombinedIdAlt3(message.destinationCombinedId) + buffer.p1Alt3(message.type) + buffer.p2(message.interfaceId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenTopEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenTopEncoder.kt new file mode 100644 index 000000000..582f6dc91 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfOpenTopEncoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfOpenTop +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class IfOpenTopEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_OPENTOP + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfOpenTop, + ) { + buffer.p2(message.interfaceId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfResyncV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfResyncV2Encoder.kt new file mode 100644 index 000000000..7f93ee623 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfResyncV2Encoder.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfResyncV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent +import net.rsprot.protocol.util.pCombinedId + +@Consistent +public class IfResyncV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_RESYNC_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfResyncV2, + ) { + buffer.p2(message.topLevelInterface) + buffer.p2(message.subInterfaces.size) + for (subInterface in message.subInterfaces) { + buffer.pCombinedId(subInterface.destinationCombinedId) + buffer.p2(subInterface.interfaceId) + buffer.p1(subInterface.type) + } + for (events in message.events) { + buffer.pCombinedId(events.combinedId) + buffer.p2(events.start) + buffer.p2(events.end) + buffer.p4(events.events1) + buffer.p4(events.events2) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAngleEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAngleEncoder.kt new file mode 100644 index 000000000..b1203af5d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAngleEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetAngle +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfSetAngleEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETANGLE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetAngle, + ) { + buffer.p2Alt2(message.angleY) + buffer.p2(message.angleX) + buffer.p2Alt1(message.zoom) + buffer.pCombinedIdAlt3(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAnimEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAnimEncoder.kt new file mode 100644 index 000000000..c73ac9c64 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetAnimEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetAnim +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedId + +public class IfSetAnimEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETANIM + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetAnim, + ) { + buffer.pCombinedId(message.combinedId) + buffer.p2(message.anim) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetColourEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetColourEncoder.kt new file mode 100644 index 000000000..6a4c092f2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetColourEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetColour +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfSetColourEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETCOLOUR + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetColour, + ) { + buffer.pCombinedIdAlt3(message.combinedId) + buffer.p2Alt3(message.colour15BitPacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetEventsV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetEventsV2Encoder.kt new file mode 100644 index 000000000..facb9f1f9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetEventsV2Encoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetEventsV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt1 + +public class IfSetEventsV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETEVENTS_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetEventsV2, + ) { + // The function uses arguments in this order: + // component, start, end, events1, events2 + buffer.pCombinedIdAlt1(message.combinedId) + buffer.p2(message.start) + buffer.p2Alt1(message.end) + buffer.p4Alt3(message.events2) + buffer.p4(message.events1) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetHideEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetHideEncoder.kt new file mode 100644 index 000000000..73c101431 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetHideEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetHide +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedId + +public class IfSetHideEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETHIDE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetHide, + ) { + buffer.pCombinedId(message.combinedId) + buffer.p1Alt1(if (message.hidden) 1 else 0) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetModelEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetModelEncoder.kt new file mode 100644 index 000000000..6f5d5b198 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetModelEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetModel +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfSetModelEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETMODEL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetModel, + ) { + buffer.p2Alt1(message.model) + buffer.pCombinedIdAlt3(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadActiveEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadActiveEncoder.kt new file mode 100644 index 000000000..ae29b1c93 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadActiveEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetNpcHeadActive +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt1 + +public class IfSetNpcHeadActiveEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETNPCHEAD_ACTIVE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetNpcHeadActive, + ) { + buffer.p2Alt2(message.index) + buffer.pCombinedIdAlt1(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadEncoder.kt new file mode 100644 index 000000000..5759300e2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetNpcHeadEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetNpcHead +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt1 + +public class IfSetNpcHeadEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETNPCHEAD + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetNpcHead, + ) { + buffer.pCombinedIdAlt1(message.combinedId) + buffer.p2(message.npc) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetObjectEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetObjectEncoder.kt new file mode 100644 index 000000000..b6d80d2d0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetObjectEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetObject +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 + +public class IfSetObjectEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETOBJECT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetObject, + ) { + buffer.p4Alt2(message.count) + buffer.pCombinedIdAlt2(message.combinedId) + buffer.p2Alt1(message.obj) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerHeadEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerHeadEncoder.kt new file mode 100644 index 000000000..78520c083 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerHeadEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPlayerHead +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 + +public class IfSetPlayerHeadEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPLAYERHEAD + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPlayerHead, + ) { + buffer.pCombinedIdAlt2(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBaseColourEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBaseColourEncoder.kt new file mode 100644 index 000000000..e94a2eb4a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBaseColourEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPlayerModelBaseColour +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfSetPlayerModelBaseColourEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPLAYERMODEL_BASECOLOUR + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPlayerModelBaseColour, + ) { + buffer.p1(message.index) + buffer.pCombinedIdAlt3(message.combinedId) + buffer.p1Alt1(message.colour) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBodyTypeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBodyTypeEncoder.kt new file mode 100644 index 000000000..8a921bf51 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelBodyTypeEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPlayerModelBodyType +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedId + +public class IfSetPlayerModelBodyTypeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPLAYERMODEL_BODYTYPE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPlayerModelBodyType, + ) { + buffer.pCombinedId(message.combinedId) + buffer.p1Alt2(message.bodyType) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelObjEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelObjEncoder.kt new file mode 100644 index 000000000..6e71e6787 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelObjEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPlayerModelObj +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 + +public class IfSetPlayerModelObjEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPLAYERMODEL_OBJ + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPlayerModelObj, + ) { + buffer.pCombinedIdAlt2(message.combinedId) + buffer.p4Alt1(message.obj) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelSelfEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelSelfEncoder.kt new file mode 100644 index 000000000..29c9bf3af --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPlayerModelSelfEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPlayerModelSelf +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 + +public class IfSetPlayerModelSelfEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPLAYERMODEL_SELF + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPlayerModelSelf, + ) { + // The boolean is inverted client-sided, it's more of a "skip copying" + buffer.pCombinedIdAlt2(message.combinedId) + buffer.p1Alt2(if (message.copyObjs) 0 else 1) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPositionEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPositionEncoder.kt new file mode 100644 index 000000000..c214fea38 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetPositionEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetPosition +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt1 + +public class IfSetPositionEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETPOSITION + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetPosition, + ) { + buffer.pCombinedIdAlt1(message.combinedId) + buffer.p2Alt1(message.y) + buffer.p2(message.x) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetRotateSpeedEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetRotateSpeedEncoder.kt new file mode 100644 index 000000000..5f24a58d2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetRotateSpeedEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetRotateSpeed +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt1 + +public class IfSetRotateSpeedEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETROTATESPEED + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetRotateSpeed, + ) { + // Note: xSpeed is shifted left by 16 bits (xSpeed << 16) in the client + buffer.pCombinedIdAlt1(message.combinedId) + buffer.p2(message.ySpeed) + buffer.p2(message.xSpeed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetScrollPosEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetScrollPosEncoder.kt new file mode 100644 index 000000000..916df05c8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetScrollPosEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetScrollPos +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt3 + +public class IfSetScrollPosEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETSCROLLPOS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetScrollPos, + ) { + buffer.pCombinedIdAlt3(message.combinedId) + buffer.p2Alt1(message.scrollPos) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetTextEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetTextEncoder.kt new file mode 100644 index 000000000..8475d3246 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/interfaces/IfSetTextEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.interfaces + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.interfaces.IfSetText +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedIdAlt2 + +public class IfSetTextEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.IF_SETTEXT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: IfSetText, + ) { + buffer.pjstr(message.text) + buffer.pCombinedIdAlt2(message.combinedId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvFullEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvFullEncoder.kt new file mode 100644 index 000000000..dd0d4df9e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvFullEncoder.kt @@ -0,0 +1,40 @@ +package net.rsprot.protocol.game.outgoing.codec.inv + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.game.outgoing.inv.InventoryObject +import net.rsprot.protocol.game.outgoing.inv.UpdateInvFull +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.util.pCombinedId + +public class UpdateInvFullEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_INV_FULL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateInvFull, + ) { + buffer.pCombinedId(message.combinedId) + buffer.p2(message.inventoryId) + val capacity = message.capacity + buffer.p2(capacity) + for (i in 0..= 255) { + buffer.p4(count) + } + buffer.p2Alt2(InventoryObject.getId(obj) + 1) + } + message.returnInventory() + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvPartialEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvPartialEncoder.kt new file mode 100644 index 000000000..84f1e2ff3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvPartialEncoder.kt @@ -0,0 +1,41 @@ +package net.rsprot.protocol.game.outgoing.codec.inv + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.game.outgoing.inv.InventoryObject +import net.rsprot.protocol.game.outgoing.inv.UpdateInvPartial +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent +import net.rsprot.protocol.util.pCombinedId + +@Consistent +public class UpdateInvPartialEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_INV_PARTIAL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateInvPartial, + ) { + buffer.pCombinedId(message.combinedId) + buffer.p2(message.inventoryId) + for (i in 0..= 0xFF) { + buffer.p4(count) + } + } + message.returnInventory() + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvStopTransmitEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvStopTransmitEncoder.kt new file mode 100644 index 000000000..70cf2ce95 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/inv/UpdateInvStopTransmitEncoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.outgoing.codec.inv + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.inv.UpdateInvStopTransmit +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateInvStopTransmitEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_INV_STOPTRANSMIT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateInvStopTransmit, + ) { + buffer.p2(message.inventoryId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutEncoder.kt new file mode 100644 index 000000000..8572b07d9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.logout + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.logout.Logout +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class LogoutEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.LOGOUT +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutTransferEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutTransferEncoder.kt new file mode 100644 index 000000000..b911c7732 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutTransferEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.logout + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.logout.LogoutTransfer +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class LogoutTransferEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.LOGOUT_TRANSFER + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LogoutTransfer, + ) { + buffer.pjstr(message.host) + buffer.p2(message.id) + buffer.p4(message.properties) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutWithReasonEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutWithReasonEncoder.kt new file mode 100644 index 000000000..b20476615 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/logout/LogoutWithReasonEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.logout + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.logout.LogoutWithReason +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class LogoutWithReasonEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.LOGOUT_WITHREASON + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LogoutWithReason, + ) { + buffer.p1(message.reason) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildNormalEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildNormalEncoder.kt new file mode 100644 index 000000000..03d3d0e79 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildNormalEncoder.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.outgoing.codec.map + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.map.StaticRebuildMessage +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class RebuildNormalEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.REBUILD_NORMAL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: StaticRebuildMessage, + ) { + // We have to use the same encoder as it relies on the prot + // under the hood to map the encoders down + // if (message is RebuildLogin) { + // val gpiInitBlock = message.gpiInitBlock + // buffer.buffer.writeBytes( + // gpiInitBlock, + // gpiInitBlock.readerIndex(), + // gpiInitBlock.readableBytes(), + // ) + // } + buffer.p2(message.zoneZ) + buffer.p2Alt3(message.zoneX) + buffer.p2Alt1(message.worldArea) + buffer.p2(message.keys.size) + for (xteaKey in message.keys) { + for (intKey in xteaKey.key) { + buffer.p4(intKey) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildRegionEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildRegionEncoder.kt new file mode 100644 index 000000000..dea488f06 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildRegionEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.map + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.codec.map.util.encodeRegion +import net.rsprot.protocol.game.outgoing.map.RebuildRegion +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class RebuildRegionEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.REBUILD_REGION + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: RebuildRegion, + ) { + buffer.p2(message.zoneZ) + buffer.p2Alt3(message.zoneX) + buffer.p1Alt3(if (message.reload) 1 else 0) + + encodeRegion(buffer, message.zones) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildWorldEntityV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildWorldEntityV2Encoder.kt new file mode 100644 index 000000000..fd664e867 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/RebuildWorldEntityV2Encoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.map + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.codec.map.util.encodeRegion +import net.rsprot.protocol.game.outgoing.map.RebuildWorldEntityV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class RebuildWorldEntityV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.REBUILD_WORLDENTITY_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: RebuildWorldEntityV2, + ) { + buffer.p2(message.baseX) + buffer.p2(message.baseZ) + encodeRegion(buffer, message.zones) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/util/RegionEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/util/RegionEncoder.kt new file mode 100644 index 000000000..578d3767c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/map/util/RegionEncoder.kt @@ -0,0 +1,100 @@ +package net.rsprot.protocol.game.outgoing.codec.map.util + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.bitbuffer.toBitBuf +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.game.outgoing.map.util.RebuildRegionZone + +/** + * The maximum theoretical number of mapsquares that can be sent in a single + * rebuild region packet. + */ +private const val MAX_POTENTIAL_MAPSQUARES = 4 * 13 * 13 + +/** + * A thread-local implementation of mapsquares and their keys. + * As we need to trim our data set down to distinct mapsquares, + * doing so with new lists all the time can be quite wasteful, especially + * knowing how volatile the actual counts can be. + * To minimize the garbage created (in this case, to none), + * we use thread-local implementations for distinct mapsquares. + */ +private val distinctMapsquares = + ThreadLocal.withInitial { + IntArray(MAX_POTENTIAL_MAPSQUARES) to + Array(4 * 13 * 13) { + XteaKey.ZERO + } + } + +internal fun encodeRegion( + buffer: JagByteBuf, + zones: List, +) { + // Xtea count, temporary value + val marker = buffer.writerIndex() + buffer.p2(0) + + var xteaCount = 0 + val (mapsquares, xteas) = distinctMapsquares.get() + val maxBitBufByteCount = ((27 * zones.size) + 32) ushr 5 + val maxXteaByteCount = 2 + (4 * 4 * zones.size) + // Ensure the correct number of writable bytes ahead of time for the worst case scenario + // This is due to our bit buffer implementation by default not ensuring this + buffer.buffer.ensureWritable(maxBitBufByteCount + maxXteaByteCount) + val bitbuf = buffer.buffer.toBitBuf() + bitbuf.use { + for (zone in zones) { + if (zone == null) { + bitbuf.pBits(1, 0) + continue + } + bitbuf.pBits(1, 1) + bitbuf.pBits(26, zone.referenceZone.packed) + val mapsquareId = zone.referenceZone.mapsquareId + if (contains(mapsquares, xteaCount, mapsquareId)) { + continue + } + mapsquares[xteaCount] = mapsquareId + xteas[xteaCount] = zone.key + xteaCount++ + } + } + // Write the real xtea count + val writerIndex = buffer.writerIndex() + buffer.writerIndex(marker) + buffer.p2(xteaCount) + buffer.writerIndex(writerIndex) + + for (i in 0.. { + override val prot: ServerProt = GameServerProt.HIDELOCOPS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: HideLocOps, + ) { + buffer.pboolean(message.hidden) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideNpcOpsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideNpcOpsEncoder.kt new file mode 100644 index 000000000..d1016e89e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideNpcOpsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.HideNpcOps +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class HideNpcOpsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.HIDENPCOPS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: HideNpcOps, + ) { + buffer.pboolean(message.hidden) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideObjOpsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideObjOpsEncoder.kt new file mode 100644 index 000000000..79e971493 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HideObjOpsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.HideObjOps +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class HideObjOpsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.HIDEOBJOPS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: HideObjOps, + ) { + buffer.pboolean(message.hidden) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HintArrowEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HintArrowEncoder.kt new file mode 100644 index 000000000..18b48b7be --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HintArrowEncoder.kt @@ -0,0 +1,48 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.HintArrow +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class HintArrowEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.HINT_ARROW + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: HintArrow, + ) { + when (val type = message.type) { + is HintArrow.NpcHintArrow -> { + buffer.p1(1) + buffer.p2(type.index) + buffer.skipWrite(3) + } + is HintArrow.PlayerHintArrow -> { + buffer.p1(10) + buffer.p2(type.index) + buffer.skipWrite(3) + } + is HintArrow.TileHintArrow -> { + buffer.p1(type.positionId) + buffer.p2(type.x) + buffer.p2(type.z) + buffer.p1(type.height) + } + is HintArrow.WorldEntityHintArrow -> { + buffer.p1(11) + buffer.p2(type.index) + buffer.p3(type.height) + } + HintArrow.ResetHintArrow -> { + buffer.p1(0) + buffer.skipWrite(5) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HiscoreReplyEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HiscoreReplyEncoder.kt new file mode 100644 index 000000000..9b78b20a9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/HiscoreReplyEncoder.kt @@ -0,0 +1,46 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.HiscoreReply +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class HiscoreReplyEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.HISCORE_REPLY + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: HiscoreReply, + ) { + buffer.p1(message.requestId) + when (val response = message.response) { + is HiscoreReply.FailedHiscoreReply -> { + buffer.p1(1) + buffer.pjstr(response.reason) + } + is HiscoreReply.SuccessfulHiscoreReply -> { + buffer.p1(0) + buffer.p1(1) + buffer.p1(response.statResults.size) + for (stat in response.statResults) { + buffer.p2(stat.id) + buffer.p4(stat.rank) + buffer.p4(stat.result) + } + buffer.p4(response.overallRank) + buffer.p8(response.overallExperience) + buffer.p2(response.activityResults.size) + for (activity in response.activityResults) { + buffer.p2(activity.id) + buffer.p4(activity.rank) + buffer.p4(activity.result) + } + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/MinimapToggleEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/MinimapToggleEncoder.kt new file mode 100644 index 000000000..881f00e37 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/MinimapToggleEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.MinimapToggle +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MinimapToggleEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MINIMAP_TOGGLE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MinimapToggle, + ) { + buffer.p1(message.minimapState) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/PacketGroupStartEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/PacketGroupStartEncoder.kt new file mode 100644 index 000000000..74294052b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/PacketGroupStartEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.PacketGroupStart +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class PacketGroupStartEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.PACKET_GROUP_START + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: PacketGroupStart, + ) { + // The payload will be overwritten as part of the Netty handler + buffer.p2(0) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ReflectionCheckerEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ReflectionCheckerEncoder.kt new file mode 100644 index 000000000..25260eef6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ReflectionCheckerEncoder.kt @@ -0,0 +1,71 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.ReflectionChecker +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ReflectionCheckerEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.REFLECTION_CHECKER + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ReflectionChecker, + ) { + val checks = message.checks + buffer.p1(checks.size) + buffer.p4(message.id) + for (check in checks) { + when (check) { + is ReflectionChecker.GetFieldValue -> { + buffer.p1(0) + buffer.pjstr(check.className) + buffer.pjstr(check.fieldName) + } + is ReflectionChecker.SetFieldValue -> { + buffer.p1(1) + buffer.pjstr(check.className) + buffer.pjstr(check.fieldName) + buffer.p4(check.value) + } + is ReflectionChecker.GetFieldModifiers -> { + buffer.p1(2) + buffer.pjstr(check.className) + buffer.pjstr(check.fieldName) + } + is ReflectionChecker.InvokeMethod -> { + buffer.p1(3) + buffer.pjstr(check.className) + buffer.pjstr(check.methodName) + val parameterClasses = check.parameterClasses + val parameterValues = check.parameterValues + buffer.p1(parameterClasses.size) + for (parameterClass in parameterClasses) { + buffer.pjstr(parameterClass) + } + buffer.pjstr(check.returnClass) + for (parameterValue in parameterValues) { + buffer.p4(parameterValue.size) + buffer.pdata(parameterValue) + } + } + is ReflectionChecker.GetMethodModifiers -> { + buffer.p1(4) + buffer.pjstr(check.className) + buffer.pjstr(check.methodName) + val parameterClasses = check.parameterClasses + buffer.p1(parameterClasses.size) + for (parameterClass in parameterClasses) { + buffer.pjstr(parameterClass) + } + buffer.pjstr(check.returnClass) + } + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetAnimsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetAnimsEncoder.kt new file mode 100644 index 000000000..f8eba942a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetAnimsEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.ResetAnims +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResetAnimsEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.RESET_ANIMS +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetInteractionModeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetInteractionModeEncoder.kt new file mode 100644 index 000000000..7f54aacef --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ResetInteractionModeEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.ResetInteractionMode +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ResetInteractionModeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.RESET_INTERACTION_MODE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ResetInteractionMode, + ) { + buffer.p2(message.worldId) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SendPingEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SendPingEncoder.kt new file mode 100644 index 000000000..5e39ddea1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SendPingEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.SendPing +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SendPingEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SEND_PING + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SendPing, + ) { + buffer.p4(message.value1) + buffer.p4(message.value2) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ServerTickEndEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ServerTickEndEncoder.kt new file mode 100644 index 000000000..c3fe0d485 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ServerTickEndEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.ServerTickEnd +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ServerTickEndEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.SERVER_TICK_END +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetHeatmapEnabledEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetHeatmapEnabledEncoder.kt new file mode 100644 index 000000000..d6e695f92 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetHeatmapEnabledEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.SetHeatmapEnabled +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetHeatmapEnabledEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_HEATMAP_ENABLED + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetHeatmapEnabled, + ) { + buffer.pboolean(message.enabled) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetInteractionModeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetInteractionModeEncoder.kt new file mode 100644 index 000000000..e4ef41560 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SetInteractionModeEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.SetInteractionMode +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetInteractionModeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_INTERACTION_MODE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetInteractionMode, + ) { + buffer.p2(message.worldId) + buffer.p1(message.tileInteractionMode) + buffer.p1(message.entityInteractionMode) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SiteSettingsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SiteSettingsEncoder.kt new file mode 100644 index 000000000..d89ac6342 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/SiteSettingsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.SiteSettings +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SiteSettingsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SITE_SETTINGS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SiteSettings, + ) { + buffer.pjstr(message.settings) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV1Encoder.kt new file mode 100644 index 000000000..3f6f698e4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV1Encoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.UpdateRebootTimerV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateRebootTimerV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_REBOOT_TIMER_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateRebootTimerV1, + ) { + buffer.p2Alt3(message.gameCycles) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV2Encoder.kt new file mode 100644 index 000000000..0608d7ab7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateRebootTimerV2Encoder.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.UpdateRebootTimerV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateRebootTimerV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_REBOOT_TIMER_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateRebootTimerV2, + ) { + when (val mes = message.messageType) { + UpdateRebootTimerV2.ClearUpdateMessage -> { + buffer.pjstr(CANCEL) + } + UpdateRebootTimerV2.IgnoreUpdateMessage -> { + buffer.pjstr("") + } + is UpdateRebootTimerV2.SetUpdateMessage -> { + buffer.pjstr(mes.message) + } + } + buffer.p2Alt2(message.gameCycles) + } + + private companion object { + private const val CANCEL: String = "\u0018" + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateUid192Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateUid192Encoder.kt new file mode 100644 index 000000000..a57a41259 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UpdateUid192Encoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.crypto.crc.CyclicRedundancyCheck +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.UpdateUid192 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateUid192Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_UID192 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateUid192, + ) { + buffer.pdata(message.uid) + buffer.p4(CyclicRedundancyCheck.computeCrc32(message.uid)) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UrlOpenEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UrlOpenEncoder.kt new file mode 100644 index 000000000..ae55f520c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/UrlOpenEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.UrlOpen +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UrlOpenEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.URL_OPEN + + override val encryptedPayload: Boolean + get() = true + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UrlOpen, + ) { + buffer.pjstr(message.url) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ZBufEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ZBufEncoder.kt new file mode 100644 index 000000000..8b312a827 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/client/ZBufEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.client + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.client.ZBuf +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ZBufEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.ZBUF + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ZBuf, + ) { + buffer.pboolean(message.enabled) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/AccountFlagsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/AccountFlagsEncoder.kt new file mode 100644 index 000000000..809a50e14 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/AccountFlagsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.AccountFlags +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class AccountFlagsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.ACCOUNT_FLAGS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: AccountFlags, + ) { + buffer.p8(message.flags) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsEncoder.kt new file mode 100644 index 000000000..3e18f3711 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.ChatFilterSettings +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ChatFilterSettingsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CHAT_FILTER_SETTINGS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ChatFilterSettings, + ) { + buffer.p1(message.tradeChatFilter) + buffer.p1(message.publicChatFilter) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsPrivateChatEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsPrivateChatEncoder.kt new file mode 100644 index 000000000..63ef40873 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/ChatFilterSettingsPrivateChatEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.ChatFilterSettingsPrivateChat +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class ChatFilterSettingsPrivateChatEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.CHAT_FILTER_SETTINGS_PRIVATECHAT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ChatFilterSettingsPrivateChat, + ) { + buffer.p1(message.privateChatFilter) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/MessageGameEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/MessageGameEncoder.kt new file mode 100644 index 000000000..5a4529a0f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/MessageGameEncoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.MessageGame +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessageGameEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_GAME + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessageGame, + ) { + buffer.pSmart1or2(message.type) + val name = message.name + if (name != null) { + buffer.p1(1) + buffer.pjstr(name) + } else { + buffer.p1(0) + } + buffer.pjstr(message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/RunClientScriptEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/RunClientScriptEncoder.kt new file mode 100644 index 000000000..85c55df4b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/RunClientScriptEncoder.kt @@ -0,0 +1,55 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.RunClientScript +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent +import java.nio.CharBuffer + +@Consistent +public class RunClientScriptEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.RUNCLIENTSCRIPT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: RunClientScript, + ) { + val types = message.types + val values = message.values + buffer.pjstr(CharBuffer.wrap(types)) + val length = types.size + for (i in (length - 1) downTo 0) { + val type = types[i] + val value = values[i] + when (type) { + 'W' -> { + value as IntArray + + buffer.pVarInt2(value.size) + for (element in value) { + buffer.pVarInt2s(element) + } + } + 'X' -> { + value as Array<*> + + buffer.pVarInt2(value.size) + for (element in value) { + buffer.pjstr(element as String) + } + } + 's' -> { + buffer.pjstr(value as String) + } + else -> { + buffer.p4(value as Int) + } + } + } + buffer.p4(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV1Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV1Encoder.kt new file mode 100644 index 000000000..013acd950 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV1Encoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.SetMapFlagV1 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetMapFlagV1Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_MAP_FLAG_V1 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetMapFlagV1, + ) { + buffer.p1(message.xInBuildArea) + buffer.p1(message.zInBuildArea) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV2Encoder.kt new file mode 100644 index 000000000..dd5aca0d8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetMapFlagV2Encoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.SetMapFlagV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class SetMapFlagV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_MAP_FLAG_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetMapFlagV2, + ) { + buffer.p4(message.coordGrid.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetPlayerOpEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetPlayerOpEncoder.kt new file mode 100644 index 000000000..76cff8cc3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/SetPlayerOpEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.SetPlayerOp +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class SetPlayerOpEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_PLAYER_OP + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetPlayerOp, + ) { + buffer.p1Alt3(if (message.priority) 1 else 0) + buffer.pjstr(message.op ?: "null") + buffer.p1Alt1(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/TriggerOnDialogAbortEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/TriggerOnDialogAbortEncoder.kt new file mode 100644 index 000000000..4edad7e14 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/TriggerOnDialogAbortEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.TriggerOnDialogAbort +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class TriggerOnDialogAbortEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.TRIGGER_ONDIALOGABORT +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunEnergyEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunEnergyEncoder.kt new file mode 100644 index 000000000..1e36e76ea --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunEnergyEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.UpdateRunEnergy +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateRunEnergyEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_RUNENERGY + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateRunEnergy, + ) { + buffer.p2(message.runenergy) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunWeightEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunWeightEncoder.kt new file mode 100644 index 000000000..a56ce8384 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateRunWeightEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.UpdateRunWeight +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateRunWeightEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_RUNWEIGHT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateRunWeight, + ) { + buffer.p2(message.runweight) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStatV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStatV2Encoder.kt new file mode 100644 index 000000000..8117bbb5e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStatV2Encoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.UpdateStatV2 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateStatV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_STAT_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateStatV2, + ) { + buffer.p4Alt1(message.experience) + buffer.p1Alt3(message.invisibleBoostedLevel) + buffer.p1Alt1(message.stat) + buffer.p1Alt1(message.currentLevel) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStockMarketSlotEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStockMarketSlotEncoder.kt new file mode 100644 index 000000000..8765c21af --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateStockMarketSlotEncoder.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.UpdateStockMarketSlot +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateStockMarketSlotEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_STOCKMARKET_SLOT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateStockMarketSlot, + ) { + buffer.p1(message.slot) + when (val update = message.update) { + UpdateStockMarketSlot.ResetStockMarketSlot -> { + buffer.p1(0) + buffer.skipWrite(18) + } + is UpdateStockMarketSlot.SetStockMarketSlot -> { + buffer.p1(update.status) + buffer.p2(update.obj) + buffer.p4(update.price) + buffer.p4(update.count) + buffer.p4(update.completedCount) + buffer.p4(update.completedGold) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateTradingPostEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateTradingPostEncoder.kt new file mode 100644 index 000000000..4fcac4f2d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/misc/player/UpdateTradingPostEncoder.kt @@ -0,0 +1,42 @@ +package net.rsprot.protocol.game.outgoing.codec.misc.player + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.misc.player.UpdateTradingPost +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateTradingPostEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_TRADINGPOST + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateTradingPost, + ) { + when (val update = message.updateType) { + UpdateTradingPost.ResetTradingPost -> { + buffer.p1(0) + } + is UpdateTradingPost.SetTradingPostOfferList -> { + buffer.p1(1) + buffer.p8(update.age) + buffer.p2(update.obj) + buffer.p1(if (update.status) 1 else 0) + val offers = update.offers + buffer.p2(offers.size) + for (offer in offers) { + buffer.pjstr(offer.name) + buffer.pjstr(offer.previousName) + buffer.p2(offer.world) + buffer.p8(offer.time) + buffer.p4(offer.price) + buffer.p4(offer.count) + } + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/DesktopLowResolutionChangeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/DesktopLowResolutionChangeEncoder.kt new file mode 100644 index 000000000..056eb9573 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/DesktopLowResolutionChangeEncoder.kt @@ -0,0 +1,42 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo + +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.NpcAvatarDetails +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcResolutionChangeEncoder + +public class DesktopLowResolutionChangeEncoder : NpcResolutionChangeEncoder { + override val clientType: OldSchoolClientType = OldSchoolClientType.DESKTOP + + override fun encode( + bitBuffer: BitBuf, + details: NpcAvatarDetails, + extendedInfo: Boolean, + localPlayerCoordGrid: CoordGrid, + largeDistance: Boolean, + cycleCount: Int, + ) { + val numOfBitsUsed = if (largeDistance) 8 else 6 + val maximumDistanceTransmittableByBits = if (largeDistance) 0xFF else 0x3F + val deltaX = details.currentCoord.x - localPlayerCoordGrid.x + val deltaZ = details.currentCoord.z - localPlayerCoordGrid.z + + bitBuffer.pBits(16, details.index) + + bitBuffer.pBits(14, details.id) + bitBuffer.pBits(numOfBitsUsed, deltaX and maximumDistanceTransmittableByBits) + bitBuffer.pBits(numOfBitsUsed, deltaZ and maximumDistanceTransmittableByBits) + // New NPCs should always be marked as "jumping" unless they explicitly only teleported without a jump + val noJump = details.isTeleWithoutJump() && details.allocateCycle != cycleCount + bitBuffer.pBits(1, if (noJump) 0 else 1) + if (details.spawnCycle != 0) { + bitBuffer.pBits(1, 1) + bitBuffer.pBits(32, details.spawnCycle) + } else { + bitBuffer.pBits(1, 0) + } + bitBuffer.pBits(3, details.direction) + bitBuffer.pBits(1, if (extendedInfo) 1 else 0) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoLargeV5Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoLargeV5Encoder.kt new file mode 100644 index 000000000..3fa3aa010 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoLargeV5Encoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoLargeV5 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class NpcInfoLargeV5Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.NPC_INFO_LARGE_V5 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: NpcInfoLargeV5, + ) { + // Due to message extending byte buf holder, it is automatically released by the pipeline + buffer.buffer.writeBytes(message.content()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoSmallV5Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoSmallV5Encoder.kt new file mode 100644 index 000000000..5dae73758 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/NpcInfoSmallV5Encoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoSmallV5 +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class NpcInfoSmallV5Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.NPC_INFO_SMALL_V5 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: NpcInfoSmallV5, + ) { + // Due to message extending byte buf holder, it is automatically released by the pipeline + buffer.buffer.writeBytes(message.content()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/SetNpcUpdateOriginEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/SetNpcUpdateOriginEncoder.kt new file mode 100644 index 000000000..5e336f54e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/SetNpcUpdateOriginEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.info.npcinfo.SetNpcUpdateOrigin +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetNpcUpdateOriginEncoder : MessageEncoder { + override val prot: ServerProt + get() = GameServerProt.SET_NPC_UPDATE_ORIGIN + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetNpcUpdateOrigin, + ) { + buffer.p1(message.originX) + buffer.p1(message.originZ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBaseAnimationSetEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBaseAnimationSetEncoder.kt new file mode 100644 index 000000000..3444dc540 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBaseAnimationSetEncoder.kt @@ -0,0 +1,72 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BaseAnimationSet + +public class NpcBaseAnimationSetEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: BaseAnimationSet, + ): JagByteBuf { + val flag = extendedInfo.overrides + val bitCount = flag.countOneBits() + val capacity = 4 + (bitCount * 2) + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.p4Alt1(flag) + + if (flag and 0x1 != 0) { + buffer.p2Alt2(extendedInfo.turnLeftAnim.toInt()) + } + if (flag and 0x2 != 0) { + buffer.p2Alt1(extendedInfo.turnRightAnim.toInt()) + } + if (flag and 0x4 != 0) { + buffer.p2(extendedInfo.walkAnim.toInt()) + } + if (flag and 0x8 != 0) { + buffer.p2Alt1(extendedInfo.walkAnimBack.toInt()) + } + if (flag and 0x10 != 0) { + buffer.p2Alt2(extendedInfo.walkAnimLeft.toInt()) + } + if (flag and 0x20 != 0) { + buffer.p2(extendedInfo.walkAnimRight.toInt()) + } + if (flag and 0x40 != 0) { + buffer.p2Alt3(extendedInfo.runAnim.toInt()) + } + if (flag and 0x80 != 0) { + buffer.p2Alt1(extendedInfo.runAnimBack.toInt()) + } + if (flag and 0x100 != 0) { + buffer.p2Alt1(extendedInfo.runAnimLeft.toInt()) + } + if (flag and 0x200 != 0) { + buffer.p2Alt1(extendedInfo.runAnimRight.toInt()) + } + if (flag and 0x400 != 0) { + buffer.p2(extendedInfo.crawlAnim.toInt()) + } + if (flag and 0x800 != 0) { + buffer.p2Alt3(extendedInfo.crawlAnimBack.toInt()) + } + if (flag and 0x1000 != 0) { + buffer.p2Alt2(extendedInfo.crawlAnimLeft.toInt()) + } + if (flag and 0x2000 != 0) { + buffer.p2Alt2(extendedInfo.crawlAnimRight.toInt()) + } + if (flag and 0x4000 != 0) { + buffer.p2Alt2(extendedInfo.readyAnim.toInt()) + } + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBodyCustomisationEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBodyCustomisationEncoder.kt new file mode 100644 index 000000000..7a2ca9cf3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcBodyCustomisationEncoder.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BodyCustomisation + +@Suppress("DuplicatedCode") +public class NpcBodyCustomisationEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: BodyCustomisation, + ): JagByteBuf { + val customisation = extendedInfo.customisation + if (customisation == null) { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.pFlag(FLAG_RESET) + return buffer + } + val capacity = + 3 + (customisation.models.size * 2) + + (customisation.recolours.size * 2) + + (customisation.retexture.size * 2) + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + var flag = 0 + if (customisation.models.isNotEmpty()) { + flag = flag or FLAG_REMODEL + } + if (customisation.recolours.isNotEmpty()) { + flag = flag or FLAG_RECOLOUR + } + if (customisation.retexture.isNotEmpty()) { + flag = flag or FLAG_RETEXTURE + } + if (customisation.mirror != null) { + flag = flag or FLAG_MIRROR_LOCAL_PLAYER + } + buffer.pFlag(flag) + if (flag and FLAG_REMODEL != 0) { + buffer.p1Alt3(customisation.models.size) + for (model in customisation.models) { + buffer.p2(model) + } + } + if (flag and FLAG_RECOLOUR != 0) { + for (recol in customisation.recolours) { + buffer.p2(recol) + } + } + if (flag and FLAG_RETEXTURE != 0) { + for (retex in customisation.retexture) { + buffer.p2Alt3(retex) + } + } + if (flag and FLAG_MIRROR_LOCAL_PLAYER != 0) { + buffer.p1Alt2(if (customisation.mirror == true) 1 else 0) + } + return buffer + } + + private fun JagByteBuf.pFlag(value: Int) { + p1(value) + } + + private companion object { + private const val FLAG_RESET: Int = 0x1 + private const val FLAG_REMODEL: Int = 0x2 + private const val FLAG_RECOLOUR: Int = 0x4 + private const val FLAG_RETEXTURE: Int = 0x8 + private const val FLAG_MIRROR_LOCAL_PLAYER: Int = 0x10 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcCombatLevelChangeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcCombatLevelChangeEncoder.kt new file mode 100644 index 000000000..7957f36bf --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcCombatLevelChangeEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.CombatLevelChange + +public class NpcCombatLevelChangeEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: CombatLevelChange, + ): JagByteBuf { + val buffer = + alloc + .buffer(4, 4) + .toJagByteBuf() + buffer.p4Alt2(extendedInfo.level) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcExactMoveEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcExactMoveEncoder.kt new file mode 100644 index 000000000..6ba3afcb8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcExactMoveEncoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.ExactMove + +public class NpcExactMoveEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: ExactMove, + ): JagByteBuf { + val buffer = + alloc + .buffer(10, 10) + .toJagByteBuf() + buffer.p1Alt3(extendedInfo.deltaX1.toInt()) + buffer.p1Alt1(extendedInfo.deltaZ1.toInt()) + buffer.p1Alt3(extendedInfo.deltaX2.toInt()) + buffer.p1(extendedInfo.deltaZ2.toInt()) + buffer.p2Alt1(extendedInfo.delay1.toInt()) + buffer.p2Alt3(extendedInfo.delay2.toInt()) + buffer.p2(extendedInfo.direction.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFaceAngleEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFaceAngleEncoder.kt new file mode 100644 index 000000000..62b1e1168 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFaceAngleEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle + +public class NpcFaceAngleEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: FaceAngle, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2(extendedInfo.angle.toInt()) + buffer.p1(if (extendedInfo.instant) 1 else 0) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFacePathingEntityEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFacePathingEntityEncoder.kt new file mode 100644 index 000000000..b02a45bd2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcFacePathingEntityEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity + +public class NpcFacePathingEntityEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: FacePathingEntity, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2Alt3(extendedInfo.index) + buffer.p1Alt1(extendedInfo.index shr 16) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadCustomisationEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadCustomisationEncoder.kt new file mode 100644 index 000000000..7f645f3ae --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadCustomisationEncoder.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadCustomisation + +@Suppress("DuplicatedCode") +public class NpcHeadCustomisationEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: HeadCustomisation, + ): JagByteBuf { + val customisation = extendedInfo.customisation + if (customisation == null) { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.pFlag(FLAG_RESET) + return buffer + } + val capacity = + 3 + (customisation.models.size * 2) + + (customisation.recolours.size * 2) + + (customisation.retexture.size * 2) + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + var flag = 0 + if (customisation.models.isNotEmpty()) { + flag = flag or FLAG_REMODEL + } + if (customisation.recolours.isNotEmpty()) { + flag = flag or FLAG_RECOLOUR + } + if (customisation.retexture.isNotEmpty()) { + flag = flag or FLAG_RETEXTURE + } + if (customisation.mirror != null) { + flag = flag or FLAG_MIRROR_LOCAL_PLAYER + } + buffer.pFlag(flag) + if (flag and FLAG_REMODEL != 0) { + buffer.p1Alt1(customisation.models.size) + for (model in customisation.models) { + buffer.p2Alt3(model) + } + } + if (flag and FLAG_RECOLOUR != 0) { + for (recol in customisation.recolours) { + buffer.p2Alt2(recol) + } + } + if (flag and FLAG_RETEXTURE != 0) { + for (retex in customisation.retexture) { + buffer.p2Alt1(retex) + } + } + if (flag and FLAG_MIRROR_LOCAL_PLAYER != 0) { + buffer.p1(if (customisation.mirror == true) 1 else 0) + } + return buffer + } + + private fun JagByteBuf.pFlag(value: Int) { + p1(value) + } + + private companion object { + private const val FLAG_RESET: Int = 0x1 + private const val FLAG_REMODEL: Int = 0x2 + private const val FLAG_RECOLOUR: Int = 0x4 + private const val FLAG_RETEXTURE: Int = 0x8 + private const val FLAG_MIRROR_LOCAL_PLAYER: Int = 0x10 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadIconCustomisationEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadIconCustomisationEncoder.kt new file mode 100644 index 000000000..7ba88f433 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHeadIconCustomisationEncoder.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadIconCustomisation + +public class NpcHeadIconCustomisationEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: HeadIconCustomisation, + ): JagByteBuf { + val capacity = 1 + 8 * 6 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + val flag = extendedInfo.flag + buffer.p1Alt2(flag) + for (i in extendedInfo.headIconGroups.indices) { + if (flag and (1 shl i) == 0) { + continue + } + val group = extendedInfo.headIconGroups[i] + val index = extendedInfo.headIconIndices[i].toInt() + buffer.pSmart2or4null(group) + buffer.pSmart1or2null(index) + } + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHitEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHitEncoder.kt new file mode 100644 index 000000000..c260df34a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcHitEncoder.kt @@ -0,0 +1,101 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.message.toIntOrMinusOne + +@Suppress("DuplicatedCode") +public class NpcHitEncoder : OnDemandExtendedInfoEncoder { + override fun encode( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedAvatarIndex: Int, + extendedInfo: Hit, + ) { + pHits(buffer, localPlayerIndex, extendedInfo) + pHeadBars(buffer, localPlayerIndex, extendedInfo) + } + + private fun pHits( + buffer: JagByteBuf, + localPlayerIndex: Int, + info: Hit, + ) { + val countMarker = buffer.writerIndex() + buffer.skipWrite(1) + var count = 0 + for (hit in info.hitMarkList) { + // If we were the source of the hit in the first place + val tinted = localPlayerIndex == (hit.sourceIndex - 0x10_000) + val mainType = if (tinted) hit.selfType else hit.otherType + // Skip the hitsplat if it isn't meant to render to us + // Should be noted that we only check this on the main types, and not soak ones + if (mainType == UShort.MAX_VALUE) continue + val soakType = if (tinted) hit.selfSoakType else hit.otherSoakType + if (mainType.toInt() == 0x7FFE) { + buffer.pSmart1or2(0x7FFE) + } else if (soakType != UShort.MAX_VALUE) { + buffer.pSmart1or2(0x7FFF) + buffer.pSmart1or2(mainType.toInt()) + buffer.pSmart1or2(hit.value.toInt()) + buffer.pSmart1or2(soakType.toInt()) + buffer.pSmart1or2(hit.soakValue.toInt()) + } else { + buffer.pSmart1or2(mainType.toInt()) + buffer.pSmart1or2(hit.value.toInt()) + } + buffer.pSmart1or2(hit.delay.toInt()) + // Exit out of the loop if there are more than 255 hits, + // as that's the highest count we can write + if (++count >= 0xFF) { + break + } + } + val writerIndex = buffer.writerIndex() + buffer.writerIndex(countMarker) + buffer.p1Alt3(count) + buffer.writerIndex(writerIndex) + } + + private fun pHeadBars( + buffer: JagByteBuf, + localPlayerIndex: Int, + info: Hit, + ) { + val countMarker = buffer.writerIndex() + buffer.skipWrite(1) + var count = 0 + for (headBar in info.headBarList) { + val selfType = headBar.selfType.toIntOrMinusOne() + val isSelf = localPlayerIndex == (headBar.sourceIndex - 0x10_000) + if (isSelf && selfType == -1) { + continue + } + val otherType = headBar.otherType.toIntOrMinusOne() + if (!isSelf && otherType == -1) { + continue + } + val type = if (isSelf) selfType else otherType + buffer.pSmart1or2(type) + val endTime = headBar.endTime.toInt() + buffer.pSmart1or2(endTime) + if (endTime != 0x7FFF) { + buffer.pSmart1or2(headBar.startTime.toInt()) + buffer.p1Alt1(headBar.startFill.toInt()) + if (endTime > 0) { + buffer.p1(headBar.endFill.toInt()) + } + } + // Exit out of the loop if there are more than 255 head bars, + // as that's the highest count we can write + if (++count >= 0xFF) { + break + } + } + val writerIndex = buffer.writerIndex() + buffer.writerIndex(countMarker) + buffer.p1Alt1(count) + buffer.writerIndex(writerIndex) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcNameChangeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcNameChangeEncoder.kt new file mode 100644 index 000000000..eb4714bbb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcNameChangeEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NameChange + +public class NpcNameChangeEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: NameChange, + ): JagByteBuf { + val text = extendedInfo.name ?: "" + val capacity = text.length + 1 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.pjstr(text) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSayEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSayEncoder.kt new file mode 100644 index 000000000..c5bec466f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSayEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say + +public class NpcSayEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Say, + ): JagByteBuf { + val text = extendedInfo.text ?: "" + val capacity = text.length + 1 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.pjstr(text) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSequenceEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSequenceEncoder.kt new file mode 100644 index 000000000..c157fe04a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSequenceEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence + +public class NpcSequenceEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Sequence, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2Alt1(extendedInfo.id.toInt()) + buffer.p1Alt3(extendedInfo.delay.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSpotAnimEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSpotAnimEncoder.kt new file mode 100644 index 000000000..c84ea46c4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcSpotAnimEncoder.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.SpotAnim + +public class NpcSpotAnimEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: SpotAnimList, + ): JagByteBuf { + val changelist = extendedInfo.changelist + val count = changelist.cardinality() + val capacity = 1 + count * 7 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.p1Alt1(count) + val spotanims = extendedInfo.spotanims + var slot = changelist.nextSetBit(0) + while (slot != -1) { + val spotanim = SpotAnim(spotanims[slot]) + buffer.p1(slot) + buffer.p2(spotanim.id) + buffer.p4Alt2(spotanim.delay or (spotanim.height shl 16)) + slot = changelist.nextSetBit(slot + 1) + } + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTintingEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTintingEncoder.kt new file mode 100644 index 000000000..a026712a7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTintingEncoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NpcTinting + +public class NpcTintingEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: NpcTinting, + ): JagByteBuf { + val buffer = + alloc + .buffer(10, 10) + .toJagByteBuf() + val tinting = extendedInfo.global + buffer.p2Alt3(tinting.start.toInt()) + buffer.p2Alt2(tinting.end.toInt()) + buffer.p1(tinting.hue.toInt()) + buffer.p1Alt2(tinting.saturation.toInt()) + buffer.p1Alt3(tinting.lightness.toInt()) + buffer.p1Alt3(tinting.weight.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTransformationEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTransformationEncoder.kt new file mode 100644 index 000000000..3fb106372 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcTransformationEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.Transformation + +public class NpcTransformationEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Transformation, + ): JagByteBuf { + val buffer = + alloc + .buffer(2, 2) + .toJagByteBuf() + buffer.p2Alt2(extendedInfo.id.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcVisibleOpsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcVisibleOpsEncoder.kt new file mode 100644 index 000000000..f40ff6032 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/NpcVisibleOpsEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps + +public class NpcVisibleOpsEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: VisibleOps, + ): JagByteBuf { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.p1(extendedInfo.ops.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/writer/NpcAvatarExtendedInfoDesktopWriter.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/writer/NpcAvatarExtendedInfoDesktopWriter.kt new file mode 100644 index 000000000..232e9b296 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/npcinfo/extendedinfo/writer/NpcAvatarExtendedInfoDesktopWriter.kt @@ -0,0 +1,231 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.writer + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcBaseAnimationSetEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcBodyCustomisationEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcCombatLevelChangeEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcExactMoveEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcFaceAngleEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcFacePathingEntityEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcHeadCustomisationEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcHeadIconCustomisationEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcHitEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcNameChangeEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcSayEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcSequenceEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcSpotAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcTintingEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcTransformationEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.NpcVisibleOpsEncoder +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarExtendedInfo +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarExtendedInfoBlocks +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcExtendedInfoEncoders + +@Suppress("DuplicatedCode") +public class NpcAvatarExtendedInfoDesktopWriter : + AvatarExtendedInfoWriter( + OldSchoolClientType.DESKTOP, + NpcExtendedInfoEncoders( + OldSchoolClientType.DESKTOP, + NpcSpotAnimEncoder(), + NpcSayEncoder(), + NpcVisibleOpsEncoder(), + NpcExactMoveEncoder(), + NpcSequenceEncoder(), + NpcTintingEncoder(), + NpcHeadIconCustomisationEncoder(), + NpcNameChangeEncoder(), + NpcHeadCustomisationEncoder(), + NpcBodyCustomisationEncoder(), + NpcTransformationEncoder(), + NpcCombatLevelChangeEncoder(), + NpcHitEncoder(), + NpcFaceAngleEncoder(), + NpcFacePathingEntityEncoder(), + NpcBaseAnimationSetEncoder(), + ), + ) { + private fun convertFlags(constantFlags: Int): Int { + var clientFlags = 0 + if (constantFlags and NpcAvatarExtendedInfo.FACE_PATHINGENTITY != 0) { + clientFlags = clientFlags or FACE_PATHINGENTITY + } + if (constantFlags and NpcAvatarExtendedInfo.TINTING != 0) { + clientFlags = clientFlags or TINTING + } + if (constantFlags and NpcAvatarExtendedInfo.SAY != 0) { + clientFlags = clientFlags or SAY + } + if (constantFlags and NpcAvatarExtendedInfo.HITS != 0) { + clientFlags = clientFlags or HITS + } + if (constantFlags and NpcAvatarExtendedInfo.SEQUENCE != 0) { + clientFlags = clientFlags or SEQUENCE + } + if (constantFlags and NpcAvatarExtendedInfo.EXACT_MOVE != 0) { + clientFlags = clientFlags or EXACT_MOVE + } + if (constantFlags and NpcAvatarExtendedInfo.SPOTANIM != 0) { + clientFlags = clientFlags or SPOTANIM + } + if (constantFlags and NpcAvatarExtendedInfo.TRANSFORMATION != 0) { + clientFlags = clientFlags or TRANSFORMATION + } + if (constantFlags and NpcAvatarExtendedInfo.BODY_CUSTOMISATION != 0) { + clientFlags = clientFlags or BODY_CUSTOMISATION + } + if (constantFlags and NpcAvatarExtendedInfo.HEAD_CUSTOMISATION != 0) { + clientFlags = clientFlags or HEAD_CUSTOMISATION + } + if (constantFlags and NpcAvatarExtendedInfo.LEVEL_CHANGE != 0) { + clientFlags = clientFlags or LEVEL_CHANGE + } + if (constantFlags and NpcAvatarExtendedInfo.OPS != 0) { + clientFlags = clientFlags or OPS + } + if (constantFlags and NpcAvatarExtendedInfo.NAME_CHANGE != 0) { + clientFlags = clientFlags or NAME_CHANGE + } + if (constantFlags and NpcAvatarExtendedInfo.HEADICON_CUSTOMISATION != 0) { + clientFlags = clientFlags or HEADICON_CUSTOMISATION + } + if (constantFlags and NpcAvatarExtendedInfo.BAS_CHANGE != 0) { + clientFlags = clientFlags or BAS_CHANGE + } + if (constantFlags and NpcAvatarExtendedInfo.FACE_ANGLE != 0) { + clientFlags = clientFlags or FACE_ANGLE + } + return clientFlags + } + + override fun pExtendedInfo( + buffer: JagByteBuf, + localIndex: Int, + observerIndex: Int, + flag: Int, + blocks: NpcAvatarExtendedInfoBlocks, + flagWriteIndex: Int, + ) { + var clientFlag = convertFlags(flag) + if (clientFlag and 0xFF.inv() != 0) clientFlag = clientFlag or EXTENDED_SHORT + if (clientFlag and 0xFFFF.inv() != 0) clientFlag = clientFlag or EXTENDED_MEDIUM + var outFlag = clientFlag and (EXTENDED_SHORT or EXTENDED_MEDIUM) + val flagIndex = buffer.writerIndex() + + buffer.p1(clientFlag) + if (clientFlag and EXTENDED_SHORT != 0) { + buffer.p1(clientFlag shr 8) + } + if (clientFlag and EXTENDED_MEDIUM != 0) { + buffer.p1(clientFlag shr 16) + } + outFlag = outFlag or pCached(buffer, clientFlag, EXACT_MOVE, blocks.exactMove) + // old spotanim + outFlag = outFlag or pCached(buffer, clientFlag, OPS, blocks.visibleOps) + outFlag = outFlag or pCached(buffer, clientFlag, TRANSFORMATION, blocks.transformation) + outFlag = outFlag or pCached(buffer, clientFlag, HEAD_CUSTOMISATION, blocks.headCustomisation) + outFlag = outFlag or pCached(buffer, clientFlag, NAME_CHANGE, blocks.nameChange) + outFlag = outFlag or pCached(buffer, clientFlag, TINTING, blocks.tinting) + outFlag = outFlag or pCached(buffer, clientFlag, SEQUENCE, blocks.sequence) + outFlag = outFlag or pCached(buffer, clientFlag, SAY, blocks.say) + outFlag = outFlag or pCached(buffer, clientFlag, SPOTANIM, blocks.spotAnims) + outFlag = outFlag or pCached(buffer, clientFlag, FACE_ANGLE, blocks.faceAngle) + outFlag = outFlag or pOnDemand(buffer, clientFlag, HITS, blocks.hit, localIndex, observerIndex) + outFlag = outFlag or pCached(buffer, clientFlag, LEVEL_CHANGE, blocks.combatLevelChange) + outFlag = outFlag or pCached(buffer, clientFlag, BODY_CUSTOMISATION, blocks.bodyCustomisation) + outFlag = outFlag or pCached(buffer, clientFlag, FACE_PATHINGENTITY, blocks.facePathingEntity) + outFlag = outFlag or pCached(buffer, clientFlag, BAS_CHANGE, blocks.baseAnimationSet) + outFlag = outFlag or pCached(buffer, clientFlag, HEADICON_CUSTOMISATION, blocks.headIconCustomisation) + // old face coord + + if (outFlag != clientFlag) { + val finalPos = buffer.writerIndex() + buffer.writerIndex(flagIndex) + buffer.p1(outFlag) + if (outFlag and EXTENDED_SHORT != 0) { + buffer.p1(outFlag shr 8) + } + if (outFlag and EXTENDED_MEDIUM != 0) { + buffer.p1(outFlag shr 16) + } + buffer.writerIndex(finalPos) + } + } + + private fun , E : PrecomputedExtendedInfoEncoder> pCached( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pCachedData(buffer, block) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put cached mask data for $block" + } + 0 + } + } + + @Suppress("SameParameterValue") + private fun , E : OnDemandExtendedInfoEncoder> pOnDemand( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + localIndex: Int, + observerIndex: Int, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pOnDemandData(buffer, localIndex, block, observerIndex) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put on demand mask data for $block" + } + 0 + } + } + + @Suppress("unused") + private companion object { + private val logger = InlineLogger() + private const val EXTENDED_SHORT: Int = 0x20 + private const val EXTENDED_MEDIUM: Int = 0x100 + + private const val SEQUENCE: Int = 0x1 + private const val OLD_FACE_COORD_UNUSED: Int = 0x2 + private const val OLD_SPOTANIM_UNUSED: Int = 0x4 + private const val FACE_PATHINGENTITY: Int = 0x8 + private const val SAY: Int = 0x10 + private const val TRANSFORMATION: Int = 0x40 + private const val HITS: Int = 0x80 + private const val LEVEL_CHANGE: Int = 0x200 + private const val NAME_CHANGE: Int = 0x400 + private const val HEAD_CUSTOMISATION: Int = 0x800 + private const val EXACT_MOVE: Int = 0x1000 + private const val OPS: Int = 0x2000 + private const val BODY_CUSTOMISATION: Int = 0x4000 + private const val TINTING: Int = 0x8000 + private const val SPOTANIM: Int = 0x10000 + private const val FACE_ANGLE: Int = 0x20000 + private const val BAS_CHANGE: Int = 0x40000 + private const val HEADICON_CUSTOMISATION: Int = 0x80000 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/PlayerInfoEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/PlayerInfoEncoder.kt new file mode 100644 index 000000000..fff82ba26 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/PlayerInfoEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoPacket +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class PlayerInfoEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.PLAYER_INFO + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: PlayerInfoPacket, + ) { + // Due to message extending byte buf holder, it is automatically released by the pipeline + buffer.buffer.writeBytes(message.content()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerAppearanceEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerAppearanceEncoder.kt new file mode 100644 index 000000000..1e863a33d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerAppearanceEncoder.kt @@ -0,0 +1,246 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Appearance +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.ObjTypeCustomisation + +@Suppress("DuplicatedCode") +public class PlayerAppearanceEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Appearance, + ): JagByteBuf { + val intermediate = alloc.buffer(100).toJagByteBuf() + intermediate.p1(extendedInfo.bodyType.toInt()) + intermediate.p1(extendedInfo.skullIcon.toInt()) + intermediate.p1(extendedInfo.overheadIcon.toInt()) + if (extendedInfo.transformedNpcId != UShort.MAX_VALUE) { + pTransmog(intermediate, extendedInfo) + } else { + pEquipment(intermediate, extendedInfo) + } + pIdentKits(intermediate, extendedInfo) + pColours(intermediate, extendedInfo) + pBaseAnimationSet(intermediate, extendedInfo) + intermediate.pjstr(extendedInfo.name) + intermediate.p1(extendedInfo.combatLevel.toInt()) + intermediate.p2(extendedInfo.skillLevel.toInt()) + intermediate.p1(if (extendedInfo.hidden) 1 else 0) + pObjTypeCustomisations(intermediate, extendedInfo) + intermediate.pjstr(extendedInfo.beforeName) + intermediate.pjstr(extendedInfo.afterName) + intermediate.pjstr(extendedInfo.afterCombatLevel) + intermediate.p1(extendedInfo.pronoun.toInt()) + val capacity = intermediate.readableBytes() + 1 + val buffer = alloc.buffer(capacity, capacity).toJagByteBuf() + buffer.p1(capacity - 1) + try { + buffer.pdata(intermediate.buffer) + } finally { + intermediate.buffer.release() + } + return buffer + } + + private fun pTransmog( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + intermediate.p2(-1) + intermediate.p2(extendedInfo.transformedNpcId.toInt()) + } + + private fun buildHiddenWearposFlag(hidden: ByteArray): Int { + var hiddenWearposFlag = 0 + for (i in 0..<12) { + val pos = hidden[i].toInt() + val wearpos2 = pos and 0xF + if (wearpos2 != 0xF) { + hiddenWearposFlag = hiddenWearposFlag or (1 shl wearpos2) + } + val wearpos3 = pos ushr 4 and 0xF + if (wearpos3 != 0xF) { + hiddenWearposFlag = hiddenWearposFlag or (1 shl wearpos3) + } + } + return hiddenWearposFlag + } + + private fun pEquipment( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + val identKit = extendedInfo.identKit + val objs = extendedInfo.wornObjs + val hiddenWearposFlag = buildHiddenWearposFlag(extendedInfo.hiddenWearPos) + for (wearpos in 0..<12) { + if (hiddenWearposFlag and (1 shl wearpos) != 0) { + intermediate.p1(0) + continue + } + val obj = objs[wearpos].toInt() and 0xFFFF + if (obj != 0xFFFF) { + intermediate.p2(obj + 0x800) + continue + } + val identKitSlot = Appearance.identKitSlotList[wearpos] + if (identKitSlot == -1) { + intermediate.p1(0) + continue + } + val identKitValue = identKit[identKitSlot].toInt() and 0xFFFF + if (identKitValue == 0xFFFF) { + intermediate.p1(0) + } else { + intermediate.p2(identKitValue + 0x100) + } + } + } + + private fun pIdentKits( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + val identKit = extendedInfo.identKit + for (wearpos in 0..<12) { + val identKitSlot = Appearance.identKitSlotList[wearpos] + if (identKitSlot == -1) { + intermediate.p1(0) + continue + } + val identKitValue = identKit[identKitSlot].toInt() and 0xFFFF + if (identKitValue == 0xFFFF) { + intermediate.p1(0) + } else { + intermediate.p2(identKitValue + 0x100) + } + } + } + + private fun pColours( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + val colours = extendedInfo.colours + for (i in colours.indices) { + intermediate.p1(colours[i].toInt()) + } + } + + private fun pBaseAnimationSet( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + intermediate.p2(extendedInfo.readyAnim.toInt()) + intermediate.p2(extendedInfo.turnAnim.toInt()) + intermediate.p2(extendedInfo.walkAnim.toInt()) + intermediate.p2(extendedInfo.walkAnimBack.toInt()) + intermediate.p2(extendedInfo.walkAnimLeft.toInt()) + intermediate.p2(extendedInfo.walkAnimRight.toInt()) + intermediate.p2(extendedInfo.runAnim.toInt()) + } + + private fun pObjTypeCustomisations( + intermediate: JagByteBuf, + extendedInfo: Appearance, + ) { + val marker = intermediate.writerIndex() + intermediate.skipWrite(2) + val objTypeCustomisations = extendedInfo.objTypeCustomisation + var flag = 0 + for (wearpos in objTypeCustomisations.indices) { + val objTypeCustomisation = objTypeCustomisations[wearpos] ?: continue + pObjTypeCustomisation(intermediate, objTypeCustomisation) + flag = flag or (1 shl (12 - wearpos)) + } + if (extendedInfo.forceModelRefresh) flag = flag or 0x8000 + val pos = intermediate.writerIndex() + intermediate.writerIndex(marker) + intermediate.p2(flag) + intermediate.writerIndex(pos) + } + + private fun pObjTypeCustomisation( + intermediate: JagByteBuf, + customisation: ObjTypeCustomisation, + ) { + val recolIndices = customisation.recolIndices.toInt() + val retexIndices = customisation.retexIndices.toInt() + var flag = 0 + if (recolIndices != 0xFF) { + flag = flag or 0x1 + } + if (retexIndices != 0xFF) { + flag = flag or 0x2 + } + if (customisation.manWear != ObjTypeCustomisation.DEFAULT_MODEL || + customisation.womanWear != ObjTypeCustomisation.DEFAULT_MODEL + ) { + flag = flag or 0x4 + } + if (customisation.manHead != ObjTypeCustomisation.DEFAULT_MODEL || + customisation.womanHead != ObjTypeCustomisation.DEFAULT_MODEL + ) { + flag = flag or 0x8 + } + intermediate.p1(flag) + if (flag and 0x1 != 0) { + pObjTypeCustomisation( + intermediate, + recolIndices, + customisation.recol1.toInt(), + customisation.recol2.toInt(), + ) + } + if (flag and 0x2 != 0) { + pObjTypeCustomisation( + intermediate, + retexIndices, + customisation.retex1.toInt(), + customisation.retex2.toInt(), + ) + } + if (flag and 0x4 != 0) { + pObjTypeWearModels(intermediate, customisation) + } + if (flag and 0x8 != 0) { + pObjTypeHeadModels(intermediate, customisation) + } + } + + private fun pObjTypeCustomisation( + intermediate: JagByteBuf, + flag: Int, + value1: Int, + value2: Int, + ) { + intermediate.p1(flag) + if (flag and 0xF != 0xF) { + intermediate.p2(value1) + } + if (flag and 0xF0 != 0xF0) { + intermediate.p2(value2) + } + } + + private fun pObjTypeWearModels( + intermediate: JagByteBuf, + customisation: ObjTypeCustomisation, + ) { + intermediate.p2(customisation.manWear.toInt()) + intermediate.p2(customisation.womanWear.toInt()) + } + + private fun pObjTypeHeadModels( + intermediate: JagByteBuf, + customisation: ObjTypeCustomisation, + ) { + intermediate.p2(customisation.manHead.toInt()) + intermediate.p2(customisation.womanHead.toInt()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerChatEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerChatEncoder.kt new file mode 100644 index 000000000..fd0b7d4f9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerChatEncoder.kt @@ -0,0 +1,47 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Chat + +public class PlayerChatEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Chat, + ): JagByteBuf { + val codec = huffmanCodecProvider.provide() + val text = extendedInfo.text ?: "" + val colour = extendedInfo.colour.toInt() + val patternLength = if (colour in 13..20) colour - 12 else 0 + val capacity = 5 + text.length + patternLength + val buffer = + alloc + .buffer(capacity) + .toJagByteBuf() + buffer.p2Alt2(colour shl 8 or extendedInfo.effects.toInt()) + buffer.p1Alt3(extendedInfo.modicon.toInt()) + buffer.p1(if (extendedInfo.autotyper) 1 else 0) + val huffmanBuffer = + alloc + .buffer(text.length) + .toJagByteBuf() + codec.encode(huffmanBuffer, text) + buffer.p1Alt2(huffmanBuffer.readableBytes()) + try { + buffer.pdata(huffmanBuffer.buffer) + } finally { + huffmanBuffer.buffer.release() + } + if (patternLength in 1..8) { + val pattern = checkNotNull(extendedInfo.pattern) + for (i in 0.. { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: ExactMove, + ): JagByteBuf { + val buffer = + alloc + .buffer(10, 10) + .toJagByteBuf() + buffer.p1Alt2(extendedInfo.deltaX1.toInt()) + buffer.p1Alt1(extendedInfo.deltaZ1.toInt()) + buffer.p1Alt1(extendedInfo.deltaX2.toInt()) + buffer.p1Alt1(extendedInfo.deltaZ2.toInt()) + buffer.p2Alt3(extendedInfo.delay1.toInt()) + buffer.p2Alt3(extendedInfo.delay2.toInt()) + buffer.p2Alt1(extendedInfo.direction.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFaceAngleEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFaceAngleEncoder.kt new file mode 100644 index 000000000..610ef509e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFaceAngleEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle + +public class PlayerFaceAngleEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: FaceAngle, + ): JagByteBuf { + val buffer = + alloc + .buffer(2, 2) + .toJagByteBuf() + buffer.p2Alt1(extendedInfo.angle.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFacePathingEntityEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFacePathingEntityEncoder.kt new file mode 100644 index 000000000..5807cee90 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerFacePathingEntityEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity + +public class PlayerFacePathingEntityEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: FacePathingEntity, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2(extendedInfo.index) + buffer.p1Alt3(extendedInfo.index shr 16) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerHitEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerHitEncoder.kt new file mode 100644 index 000000000..eec6c7186 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerHitEncoder.kt @@ -0,0 +1,116 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.message.toIntOrMinusOne + +@Suppress("DuplicatedCode") +public class PlayerHitEncoder : OnDemandExtendedInfoEncoder { + override fun encode( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedAvatarIndex: Int, + extendedInfo: Hit, + ) { + pHits(buffer, localPlayerIndex, updatedAvatarIndex, extendedInfo) + pHeadBars(buffer, localPlayerIndex, updatedAvatarIndex, extendedInfo) + } + + private fun pHits( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedPlayerIndex: Int, + info: Hit, + ) { + val countMarker = buffer.writerIndex() + buffer.skipWrite(1) + var count = 0 + for (hit in info.hitMarkList) { + // If the hit appears on us, or we were the source of the hit in the first place + val mainType = + when (localPlayerIndex) { + (hit.sourceIndex - 0x10_000) -> hit.sourceType + updatedPlayerIndex -> hit.selfType + else -> hit.otherType + } + // Skip the hitsplat if it isn't meant to render to us + // Should be noted that we only check this on the main types, and not soak ones + if (mainType == UShort.MAX_VALUE) { + continue + } + val soakType = + when (localPlayerIndex) { + (hit.sourceIndex - 0x10_000) -> hit.sourceSoakType + updatedPlayerIndex -> hit.selfSoakType + else -> hit.otherSoakType + } + if (mainType.toInt() == 0x7FFE) { + buffer.pSmart1or2(0x7FFE) + } else if (soakType != UShort.MAX_VALUE) { + buffer.pSmart1or2(0x7FFF) + buffer.pSmart1or2(mainType.toInt()) + buffer.pSmart1or2(hit.value.toInt()) + buffer.pSmart1or2(soakType.toInt()) + buffer.pSmart1or2(hit.soakValue.toInt()) + } else { + buffer.pSmart1or2(mainType.toInt()) + buffer.pSmart1or2(hit.value.toInt()) + } + buffer.pSmart1or2(hit.delay.toInt()) + // Exit out of the loop if there are more than 255 hits, + // as that's the highest count we can write + if (++count >= 0xFF) { + break + } + } + val writerIndex = buffer.writerIndex() + buffer.writerIndex(countMarker) + buffer.p1(count) + buffer.writerIndex(writerIndex) + } + + private fun pHeadBars( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedPlayerIndex: Int, + info: Hit, + ) { + val countMarker = buffer.writerIndex() + buffer.skipWrite(1) + var count = 0 + for (headBar in info.headBarList) { + val selfType = headBar.selfType.toIntOrMinusOne() + val isSelf = + localPlayerIndex == updatedPlayerIndex || + localPlayerIndex == (headBar.sourceIndex - 0x10_000) + if (isSelf && selfType == -1) { + continue + } + val otherType = headBar.otherType.toIntOrMinusOne() + if (!isSelf && otherType == -1) { + continue + } + val type = if (isSelf) selfType else otherType + buffer.pSmart1or2(type) + val endTime = headBar.endTime.toInt() + buffer.pSmart1or2(endTime) + if (endTime != 0x7FFF) { + buffer.pSmart1or2(headBar.startTime.toInt()) + buffer.p1Alt2(headBar.startFill.toInt()) + if (endTime > 0) { + buffer.p1(headBar.endFill.toInt()) + } + } + // Exit out of the loop if there are more than 255 head bars, + // as that's the highest count we can write + if (++count >= 0xFF) { + break + } + } + val writerIndex = buffer.writerIndex() + buffer.writerIndex(countMarker) + buffer.p1Alt1(count) + buffer.writerIndex(writerIndex) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerMoveSpeedEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerMoveSpeedEncoder.kt new file mode 100644 index 000000000..105231a5e --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerMoveSpeedEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.MoveSpeed + +public class PlayerMoveSpeedEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: MoveSpeed, + ): JagByteBuf { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.p1(extendedInfo.value) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSayEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSayEncoder.kt new file mode 100644 index 000000000..afdbf5c60 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSayEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say + +public class PlayerSayEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Say, + ): JagByteBuf { + val text = extendedInfo.text ?: "" + val capacity = text.length + 1 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.pjstr(text) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSequenceEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSequenceEncoder.kt new file mode 100644 index 000000000..164a0167a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSequenceEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence + +public class PlayerSequenceEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Sequence, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2(extendedInfo.id.toInt()) + buffer.p1Alt1(extendedInfo.delay.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSpotAnimEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSpotAnimEncoder.kt new file mode 100644 index 000000000..8e8e15970 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerSpotAnimEncoder.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.SpotAnim + +public class PlayerSpotAnimEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: SpotAnimList, + ): JagByteBuf { + val changelist = extendedInfo.changelist + val count = changelist.cardinality() + val capacity = 1 + count * 7 + val buffer = + alloc + .buffer(capacity, capacity) + .toJagByteBuf() + buffer.p1Alt2(count) + val spotanims = extendedInfo.spotanims + var slot = changelist.nextSetBit(0) + while (slot != -1) { + val spotanim = SpotAnim(spotanims[slot]) + buffer.p1Alt2(slot) + buffer.p2Alt3(spotanim.id) + buffer.p4Alt1(spotanim.delay or (spotanim.height shl 16)) + slot = changelist.nextSetBit(slot + 1) + } + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTemporaryMoveSpeedEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTemporaryMoveSpeedEncoder.kt new file mode 100644 index 000000000..c0c038d49 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTemporaryMoveSpeedEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.TemporaryMoveSpeed + +public class PlayerTemporaryMoveSpeedEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: TemporaryMoveSpeed, + ): JagByteBuf { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.p1Alt1(extendedInfo.value) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTintingEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTintingEncoder.kt new file mode 100644 index 000000000..f5d2af5b0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/PlayerTintingEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.PlayerTintingList + +public class PlayerTintingEncoder : OnDemandExtendedInfoEncoder { + override fun encode( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedAvatarIndex: Int, + extendedInfo: PlayerTintingList, + ) { + val tinting = extendedInfo[localPlayerIndex] + buffer.p2Alt1(tinting.start.toInt()) + buffer.p2Alt3(tinting.end.toInt()) + buffer.p1Alt3(tinting.hue.toInt()) + buffer.p1Alt1(tinting.saturation.toInt()) + buffer.p1Alt2(tinting.lightness.toInt()) + buffer.p1Alt2(tinting.weight.toInt()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/writer/PlayerAvatarExtendedInfoDesktopWriter.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/writer/PlayerAvatarExtendedInfoDesktopWriter.kt new file mode 100644 index 000000000..c16b5d7da --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/playerinfo/extendedinfo/writer/PlayerAvatarExtendedInfoDesktopWriter.kt @@ -0,0 +1,204 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.writer + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerAppearanceEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerChatEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerExactMoveEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerFaceAngleEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerFacePathingEntityEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerHitEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerMoveSpeedEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerSayEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerSequenceEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerSpotAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerTemporaryMoveSpeedEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.PlayerTintingEncoder +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarExtendedInfo +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarExtendedInfoBlocks +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.encoder.PlayerExtendedInfoEncoders + +public class PlayerAvatarExtendedInfoDesktopWriter : + AvatarExtendedInfoWriter( + OldSchoolClientType.DESKTOP, + PlayerExtendedInfoEncoders( + OldSchoolClientType.DESKTOP, + PlayerAppearanceEncoder(), + PlayerChatEncoder(), + PlayerExactMoveEncoder(), + PlayerFaceAngleEncoder(), + PlayerFacePathingEntityEncoder(), + PlayerHitEncoder(), + PlayerMoveSpeedEncoder(), + PlayerSayEncoder(), + PlayerSequenceEncoder(), + PlayerSpotAnimEncoder(), + PlayerTemporaryMoveSpeedEncoder(), + PlayerTintingEncoder(), + ), + ) { + private fun convertFlags(constantFlags: Int): Int { + var clientFlags = 0 + if (constantFlags and PlayerAvatarExtendedInfo.APPEARANCE != 0) { + clientFlags = clientFlags or APPEARANCE + } + if (constantFlags and PlayerAvatarExtendedInfo.MOVE_SPEED != 0) { + clientFlags = clientFlags or MOVE_SPEED + } + if (constantFlags and PlayerAvatarExtendedInfo.FACE_PATHINGENTITY != 0) { + clientFlags = clientFlags or FACE_PATHINGENTITY + } + if (constantFlags and PlayerAvatarExtendedInfo.TINTING != 0) { + clientFlags = clientFlags or TINTING + } + if (constantFlags and PlayerAvatarExtendedInfo.FACE_ANGLE != 0) { + clientFlags = clientFlags or FACE_ANGLE + } + if (constantFlags and PlayerAvatarExtendedInfo.SAY != 0) { + clientFlags = clientFlags or SAY + } + if (constantFlags and PlayerAvatarExtendedInfo.HITS != 0) { + clientFlags = clientFlags or HITS + } + if (constantFlags and PlayerAvatarExtendedInfo.SEQUENCE != 0) { + clientFlags = clientFlags or SEQUENCE + } + if (constantFlags and PlayerAvatarExtendedInfo.CHAT != 0) { + clientFlags = clientFlags or CHAT + } + if (constantFlags and PlayerAvatarExtendedInfo.TEMP_MOVE_SPEED != 0) { + clientFlags = clientFlags or TEMP_MOVE_SPEED + } + if (constantFlags and PlayerAvatarExtendedInfo.EXACT_MOVE != 0) { + clientFlags = clientFlags or EXACT_MOVE + } + if (constantFlags and PlayerAvatarExtendedInfo.SPOTANIM != 0) { + clientFlags = clientFlags or SPOTANIM + } + return clientFlags + } + + override fun pExtendedInfo( + buffer: JagByteBuf, + localIndex: Int, + observerIndex: Int, + flag: Int, + blocks: PlayerAvatarExtendedInfoBlocks, + flagWriteIndex: Int, + ) { + var clientFlag = convertFlags(flag) + if (clientFlag and 0xFF.inv() != 0) clientFlag = clientFlag or EXTENDED_SHORT + if (clientFlag and 0xFFFF.inv() != 0) clientFlag = clientFlag or EXTENDED_MEDIUM + var outFlag = clientFlag and (EXTENDED_SHORT or EXTENDED_MEDIUM) + val flagIndex = buffer.writerIndex() + + buffer.p1(clientFlag) + if (clientFlag and EXTENDED_SHORT != 0) { + buffer.p1(clientFlag shr 8) + } + if (clientFlag and EXTENDED_MEDIUM != 0) { + buffer.p1(clientFlag shr 16) + } + + outFlag = outFlag or pOnDemand(buffer, clientFlag, HITS, blocks.hit, localIndex, observerIndex) + // Name extras + outFlag = outFlag or pCached(buffer, clientFlag, FACE_PATHINGENTITY, blocks.facePathingEntity) + outFlag = outFlag or pOnDemand(buffer, clientFlag, TINTING, blocks.tinting, localIndex, observerIndex) + outFlag = outFlag or pCached(buffer, clientFlag, SEQUENCE, blocks.sequence) + outFlag = outFlag or pCached(buffer, clientFlag, CHAT, blocks.chat) + outFlag = outFlag or pCached(buffer, clientFlag, FACE_ANGLE, blocks.faceAngle) + outFlag = outFlag or pCached(buffer, clientFlag, EXACT_MOVE, blocks.exactMove) + // Old chat + outFlag = outFlag or pCached(buffer, clientFlag, SAY, blocks.say) + outFlag = outFlag or pCached(buffer, clientFlag, TEMP_MOVE_SPEED, blocks.temporaryMoveSpeed) + outFlag = outFlag or pCached(buffer, clientFlag, MOVE_SPEED, blocks.moveSpeed) + outFlag = outFlag or pCached(buffer, clientFlag, SPOTANIM, blocks.spotAnims) + outFlag = outFlag or pCached(buffer, clientFlag, APPEARANCE, blocks.appearance) + + if (outFlag != clientFlag) { + val finalPos = buffer.writerIndex() + buffer.writerIndex(flagIndex) + buffer.p1(outFlag) + if (outFlag and EXTENDED_SHORT != 0) { + buffer.p1(outFlag shr 8) + } + if (outFlag and EXTENDED_MEDIUM != 0) { + buffer.p1(outFlag shr 16) + } + buffer.writerIndex(finalPos) + } + } + + private fun , E : PrecomputedExtendedInfoEncoder> pCached( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pCachedData(buffer, block) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put cached mask data for $block" + } + 0 + } + } + + private fun , E : OnDemandExtendedInfoEncoder> pOnDemand( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + localIndex: Int, + observerIndex: Int, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pOnDemandData(buffer, localIndex, block, observerIndex) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put on demand mask data for $block" + } + 0 + } + } + + @Suppress("unused") + private companion object { + private val logger = InlineLogger() + private const val EXTENDED_SHORT = 0x20 + private const val EXTENDED_MEDIUM = 0x800 + + private const val SAY = 0x1 + private const val HITS = 0x2 + private const val FACE_ANGLE = 0x4 + private const val CHAT_OLD = 0x8 + private const val SEQUENCE = 0x10 + private const val APPEARANCE = 0x40 + private const val FACE_PATHINGENTITY = 0x80 + private const val EXACT_MOVE = 0x100 + private const val CHAT = 0x200 + private const val TEMP_MOVE_SPEED = 0x400 + private const val TINTING = 0x1000 + private const val MOVE_SPEED = 0x2000 + private const val SPOTANIM = 0x10000 + + // Name extras are part of appearance nowadays, and thus will not be used on their own + private const val NAME_EXTRAS = 0x4000 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/FriendListLoadedEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/FriendListLoadedEncoder.kt new file mode 100644 index 000000000..63c4a6e9f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/FriendListLoadedEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.social + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.social.FriendListLoaded +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class FriendListLoadedEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.FRIENDLIST_LOADED +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEchoEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEchoEncoder.kt new file mode 100644 index 000000000..135439f51 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEchoEncoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.outgoing.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.social.MessagePrivateEcho +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessagePrivateEchoEncoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_PRIVATE_ECHO + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessagePrivateEcho, + ) { + buffer.pjstr(message.recipient) + val huffman = huffmanCodecProvider.provide() + huffman.encode(buffer, message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEncoder.kt new file mode 100644 index 000000000..aab9ae3af --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/MessagePrivateEncoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.social.MessagePrivate +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class MessagePrivateEncoder( + private val huffmanCodecProvider: HuffmanCodecProvider, +) : MessageEncoder { + override val prot: ServerProt = GameServerProt.MESSAGE_PRIVATE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MessagePrivate, + ) { + buffer.pjstr(message.sender) + buffer.p2(message.worldId) + buffer.p3(message.worldMessageCounter) + buffer.p1(message.chatCrownType) + val huffmanCodec = huffmanCodecProvider.provide() + huffmanCodec.encode(buffer, message.message) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateFriendListEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateFriendListEncoder.kt new file mode 100644 index 000000000..98c5440a2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateFriendListEncoder.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.social.UpdateFriendList +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateFriendListEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_FRIENDLIST + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateFriendList, + ) { + for (friend in message.friends) { + buffer.p1(if (friend.added) 1 else 0) + buffer.pjstr(friend.name) + buffer.pjstr(friend.previousName ?: "") + buffer.p2(friend.worldId) + buffer.p1(friend.rank) + buffer.p1(friend.properties) + if (friend is UpdateFriendList.OnlineFriend) { + buffer.pjstr(friend.worldName) + buffer.p1(friend.platform) + buffer.p4(friend.worldFlags) + } + buffer.pjstr(friend.notes) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateIgnoreListEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateIgnoreListEncoder.kt new file mode 100644 index 000000000..ffca81015 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/social/UpdateIgnoreListEncoder.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.codec.social + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.social.UpdateIgnoreList +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class UpdateIgnoreListEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_IGNORELIST + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateIgnoreList, + ) { + for (ignore in message.ignores) { + when (ignore) { + is UpdateIgnoreList.AddedIgnoredEntry -> { + buffer.p1(if (ignore.added) 0x1 else 0) + buffer.pjstr(ignore.name) + buffer.pjstr(ignore.previousName ?: "") + buffer.pjstr(ignore.note) + } + is UpdateIgnoreList.RemovedIgnoredEntry -> { + buffer.p1(0x4) + buffer.pjstr(ignore.name) + } + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiJingleEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiJingleEncoder.kt new file mode 100644 index 000000000..627766cf5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiJingleEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.MidiJingle +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MidiJingleEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MIDI_JINGLE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MidiJingle, + ) { + buffer.p2Alt1(message.id) + buffer.p3Alt1(message.lengthInMillis) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongStopEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongStopEncoder.kt new file mode 100644 index 000000000..5544008ee --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongStopEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.MidiSongStop +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MidiSongStopEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MIDI_SONG_STOP + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MidiSongStop, + ) { + // The order in the client remains the same for the function call at the end + // of the packet, as: + // fadeOut(fadeOutDelay, fadeOutSpeed) + buffer.p2Alt1(message.fadeOutDelay) + buffer.p2Alt3(message.fadeOutSpeed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongV2Encoder.kt new file mode 100644 index 000000000..1ce33e88f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongV2Encoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.MidiSongV2 +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MidiSongV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MIDI_SONG_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MidiSongV2, + ) { + // The order in the client remains the same for the function call at the end + // of the packet, as: + // playSongList(ids, fadeOutDelay, fadeOutSpeed, fadeInDelay, fadeInSpeed) + buffer.p2Alt1(message.fadeOutDelay) + buffer.p2Alt3(message.fadeOutSpeed) + buffer.p2Alt2(message.fadeInDelay) + buffer.p2(message.fadeInSpeed) + buffer.p2Alt3(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongWithSecondaryEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongWithSecondaryEncoder.kt new file mode 100644 index 000000000..9673a0852 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSongWithSecondaryEncoder.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.MidiSongWithSecondary +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MidiSongWithSecondaryEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MIDI_SONG_WITHSECONDARY + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MidiSongWithSecondary, + ) { + // The order in the client remains the same for the function call at the end + // of the packet, as (the ids list has primary id as the first song): + // playSongList(ids, fadeOutDelay, fadeOutSpeed, fadeInDelay, fadeInSpeed) + buffer.p2Alt1(message.fadeInSpeed) + buffer.p2(message.secondaryId) + buffer.p2Alt1(message.fadeInDelay) + buffer.p2Alt1(message.primaryId) + buffer.p2Alt1(message.fadeOutSpeed) + buffer.p2Alt1(message.fadeOutDelay) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSwapEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSwapEncoder.kt new file mode 100644 index 000000000..03e87f460 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/MidiSwapEncoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.MidiSwap +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MidiSwapEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MIDI_SWAP + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MidiSwap, + ) { + // The order in the client remains the same for the function call at the end + // of the packet, as: + // swap(fadeOutDelay, fadeOutSpeed, fadeInDelay, fadeInSpeed) + buffer.p2Alt1(message.fadeOutDelay) + buffer.p2(message.fadeInDelay) + buffer.p2Alt2(message.fadeOutSpeed) + buffer.p2Alt1(message.fadeInSpeed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/SynthSoundEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/SynthSoundEncoder.kt new file mode 100644 index 000000000..f4f35cf65 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/sound/SynthSoundEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.sound + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.sound.SynthSound +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SynthSoundEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SYNTH_SOUND + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SynthSound, + ) { + buffer.p2(message.id) + buffer.p1(message.loops) + buffer.p2(message.delay) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/LocAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/LocAnimSpecificEncoder.kt new file mode 100644 index 000000000..891cf3186 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/LocAnimSpecificEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.LocAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class LocAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.LOC_ANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LocAnimSpecific, + ) { + buffer.p1Alt2(message.locPropertiesPacked) + buffer.p2Alt2(message.id) + buffer.p3(message.coordInBuildAreaPacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/MapAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/MapAnimSpecificEncoder.kt new file mode 100644 index 000000000..e943f10f1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/MapAnimSpecificEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.MapAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class MapAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.MAP_ANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: MapAnimSpecific, + ) { + buffer.p2Alt2(message.delay) + buffer.p1(message.height) + buffer.p2Alt2(message.id) + buffer.p3Alt2(message.coordInBuildAreaPacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcAnimSpecificEncoder.kt new file mode 100644 index 000000000..699d9348c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcAnimSpecificEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.NpcAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class NpcAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.NPC_ANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: NpcAnimSpecific, + ) { + buffer.p2Alt3(message.id) + buffer.p1Alt2(message.delay) + buffer.p2Alt3(message.index) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcHeadIconSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcHeadIconSpecificEncoder.kt new file mode 100644 index 000000000..1ff764efc --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcHeadIconSpecificEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.NpcHeadIconSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class NpcHeadIconSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.NPC_HEADICON_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: NpcHeadIconSpecific, + ) { + buffer.p2(message.spriteIndex) + buffer.p2Alt1(message.index) + buffer.p1(message.headIconSlot) + buffer.p4Alt1(message.spriteGroup) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcSpotAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcSpotAnimSpecificEncoder.kt new file mode 100644 index 000000000..9f2281036 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/NpcSpotAnimSpecificEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.NpcSpotAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class NpcSpotAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.NPC_SPOTANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: NpcSpotAnimSpecific, + ) { + buffer.p2Alt3(message.index) + buffer.p4Alt3((message.height shl 16) or message.delay) + buffer.p2Alt1(message.id) + buffer.p1(message.slot) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjAddSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjAddSpecificEncoder.kt new file mode 100644 index 000000000..cd7066820 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjAddSpecificEncoder.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjAddSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjAddSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_ADD_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjAddSpecific, + ) { + // The function at the bottom of the OBJ_ADD_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_add(world, level, x, z, id, quantity, opFlags, + // timeUntilPublic, timeUntilDespawn, ownershipType, neverBecomesPublic); + buffer.p4Alt1(message.coordGrid.packed) + buffer.p2Alt3(message.timeUntilPublic) + buffer.p2Alt2(message.timeUntilDespawn) + buffer.p4Alt3(message.quantity) + buffer.p2Alt1(message.id) + buffer.p1Alt3(if (message.neverBecomesPublic) 1 else 0) + buffer.p1Alt3(message.ownershipType) + buffer.p1Alt1(message.opFlags.toInt()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCountSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCountSpecificEncoder.kt new file mode 100644 index 000000000..e48f4ff55 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCountSpecificEncoder.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjCountSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjCountSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_COUNT_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjCountSpecific, + ) { + // The function at the bottom of the OBJ_COUNT_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_count(world, level, x, z, id, oldQuantity, newQuantity) + buffer.p4Alt2(message.oldQuantity) + buffer.p4Alt2(message.newQuantity) + buffer.p2(message.id) + buffer.p4Alt1(message.coordGrid.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCustomiseSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCustomiseSpecificEncoder.kt new file mode 100644 index 000000000..def3d84a3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjCustomiseSpecificEncoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjCustomiseSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjCustomiseSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_CUSTOMISE_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjCustomiseSpecific, + ) { + // The function at the bottom of the OBJ_CUSTOMISE_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // objCustomise(world, level, x, z, id, count, recol, recolIndex, retex, retexIndex, model); + buffer.p2Alt3(message.retexIndex) + buffer.p2Alt3(message.retex) + buffer.p2Alt3(message.id) + buffer.p2Alt1(message.recolIndex) + buffer.p2(message.model) + buffer.p2(message.recol) + buffer.p4Alt2(message.coordGrid.packed) + buffer.p4Alt1(message.quantity) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjDelSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjDelSpecificEncoder.kt new file mode 100644 index 000000000..8309fb74c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjDelSpecificEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjDelSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjDelSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_DEL_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjDelSpecific, + ) { + // The function at the bottom of the OBJ_DEL_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_del(world, level, x, z, id, quantity) + buffer.p4Alt2(message.quantity) + buffer.p4Alt3(message.coordGrid.packed) + buffer.p2Alt1(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjEnabledOpsSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjEnabledOpsSpecificEncoder.kt new file mode 100644 index 000000000..ac343abd4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjEnabledOpsSpecificEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjEnabledOpsSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjEnabledOpsSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_ENABLED_OPS_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjEnabledOpsSpecific, + ) { + // The function at the bottom of the OBJ_ENABLED_OPS_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_enabledops(world, level, x, z, id, opFlags) + buffer.p2Alt2(message.id) + buffer.p1Alt2(message.opFlags.toInt()) + buffer.p4Alt3(message.coordGrid.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjUncustomiseSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjUncustomiseSpecificEncoder.kt new file mode 100644 index 000000000..0ad67fcac --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ObjUncustomiseSpecificEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ObjUncustomiseSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ObjUncustomiseSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.OBJ_UNCUSTOMISE_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ObjUncustomiseSpecific, + ) { + // The function at the bottom of the OBJ_UNCUSTOMISE_SPECIFIC has a consistent order, + // making it easy to identify all the properties of this packet: + // objUncustomise(world, level, x, z, id, count); + buffer.p4Alt3(message.quantity) + buffer.p2(message.id) + buffer.p4Alt3(message.coordGrid.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerAnimSpecificEncoder.kt new file mode 100644 index 000000000..10d7c8741 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerAnimSpecificEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.PlayerAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class PlayerAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.PLAYER_ANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: PlayerAnimSpecific, + ) { + buffer.p1(message.delay) + buffer.p2Alt3(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerSpotAnimSpecificEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerSpotAnimSpecificEncoder.kt new file mode 100644 index 000000000..aa911c2b5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/PlayerSpotAnimSpecificEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.PlayerSpotAnimSpecific +import net.rsprot.protocol.message.codec.MessageEncoder + +public class PlayerSpotAnimSpecificEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.PLAYER_SPOTANIM_SPECIFIC + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: PlayerSpotAnimSpecific, + ) { + buffer.p4Alt1((message.height shl 16) or message.delay) + buffer.p2(message.id) + buffer.p1Alt2(message.slot) + buffer.p2Alt3(message.index) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ProjAnimSpecificV4Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ProjAnimSpecificV4Encoder.kt new file mode 100644 index 000000000..3d5ae7d8c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/specific/ProjAnimSpecificV4Encoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.codec.specific + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.specific.ProjAnimSpecificV4 +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ProjAnimSpecificV4Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.PROJANIM_SPECIFIC_V4 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: ProjAnimSpecificV4, + ) { + buffer.p2Alt3(message.startHeight) + buffer.p2Alt2(message.id) + buffer.p2Alt3(message.endHeight) + buffer.p2Alt1(message.startTime) + buffer.p2Alt3(message.endTime) + buffer.p4(message.start.packed) + buffer.p2Alt1(message.progress) + buffer.p3Alt3(message.sourceIndex) + buffer.p1Alt1(message.angle) + buffer.p3Alt1(message.targetIndex) + buffer.p4(message.end.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpLargeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpLargeEncoder.kt new file mode 100644 index 000000000..ff99eaeae --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpLargeEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.varp + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.varp.VarpLarge +import net.rsprot.protocol.message.codec.MessageEncoder + +public class VarpLargeEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.VARP_LARGE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: VarpLarge, + ) { + buffer.p4Alt1(message.value) + buffer.p2Alt3(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpResetEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpResetEncoder.kt new file mode 100644 index 000000000..82ab99e15 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpResetEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.varp + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.varp.VarpReset +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class VarpResetEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.VARP_RESET +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSmallEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSmallEncoder.kt new file mode 100644 index 000000000..3c68dc863 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSmallEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.varp + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.varp.VarpSmall +import net.rsprot.protocol.message.codec.MessageEncoder + +public class VarpSmallEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.VARP_SMALL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: VarpSmall, + ) { + buffer.p2Alt1(message.id) + buffer.p1Alt2(message.value) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSyncEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSyncEncoder.kt new file mode 100644 index 000000000..106de0f48 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/varp/VarpSyncEncoder.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.codec.varp + +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.varp.VarpSync +import net.rsprot.protocol.message.codec.NoOpMessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class VarpSyncEncoder : NoOpMessageEncoder { + override val prot: ServerProt = GameServerProt.VARP_SYNC +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/SetActiveWorldV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/SetActiveWorldV2Encoder.kt new file mode 100644 index 000000000..2651aacae --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/SetActiveWorldV2Encoder.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.game.outgoing.codec.worldentity + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.worldentity.SetActiveWorldV2 +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.metadata.Consistent + +@Consistent +public class SetActiveWorldV2Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.SET_ACTIVE_WORLD_V2 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: SetActiveWorldV2, + ) { + when (val type = message.worldType) { + is SetActiveWorldV2.RootWorldType -> { + // The slot is ignored for root world updates + buffer.p2(-1) + buffer.p1(type.activeLevel) + } + is SetActiveWorldV2.DynamicWorldType -> { + buffer.p2(type.index) + buffer.p1(type.activeLevel) + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/WorldEntityInfoV7Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/WorldEntityInfoV7Encoder.kt new file mode 100644 index 000000000..72618eabe --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/WorldEntityInfoV7Encoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.game.outgoing.codec.worldentity + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfoV7Packet +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.message.codec.MessageEncoder + +public class WorldEntityInfoV7Encoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.WORLDENTITY_INFO_V7 + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: WorldEntityInfoV7Packet, + ) { + // Due to message extending byte buf holder, it is automatically released by the pipeline + buffer.buffer.writeBytes(message.content()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityAvatarExtendedInfoDesktopWriter.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityAvatarExtendedInfoDesktopWriter.kt new file mode 100644 index 000000000..a43107705 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityAvatarExtendedInfoDesktopWriter.kt @@ -0,0 +1,106 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.codec.worldentity.extendedinfo + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarExtendedInfo +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarExtendedInfoBlocks +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.worldentityinfo.encoder.WorldEntityExtendedInfoEncoders + +public class WorldEntityAvatarExtendedInfoDesktopWriter : + AvatarExtendedInfoWriter( + OldSchoolClientType.DESKTOP, + WorldEntityExtendedInfoEncoders( + OldSchoolClientType.DESKTOP, + WorldEntitySequenceEncoder(), + WorldEntityVisibleOpsEncoder(), + ), + ) { + private fun convertFlags(constantFlags: Int): Int { + var clientFlags = 0 + if (constantFlags and WorldEntityAvatarExtendedInfo.SEQUENCE != 0) { + clientFlags = clientFlags or SEQUENCE + } + if (constantFlags and WorldEntityAvatarExtendedInfo.VISIBLE_OPS != 0) { + clientFlags = clientFlags or VISIBLE_OPS + } + return clientFlags + } + + @Suppress("DuplicatedCode") + override fun pExtendedInfo( + buffer: JagByteBuf, + localIndex: Int, + observerIndex: Int, + flag: Int, + blocks: WorldEntityAvatarExtendedInfoBlocks, + flagWriteIndex: Int, + ) { + val clientFlag = convertFlags(flag) + var outFlag = clientFlag + + outFlag = outFlag or pCached(buffer, clientFlag, SEQUENCE, blocks.sequence) + outFlag = outFlag or pCached(buffer, clientFlag, VISIBLE_OPS, blocks.visibleOps) + + val finalPos = buffer.writerIndex() + buffer.writerIndex(flagWriteIndex) + buffer.p1(outFlag) + buffer.writerIndex(finalPos) + } + + private fun , E : PrecomputedExtendedInfoEncoder> pCached( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pCachedData(buffer, block) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put cached mask data for $block" + } + 0 + } + } + + @Suppress("SameParameterValue", "unused") + private fun , E : OnDemandExtendedInfoEncoder> pOnDemand( + buffer: JagByteBuf, + clientFlag: Int, + blockFlag: Int, + block: T, + localIndex: Int, + observerIndex: Int, + ): Int { + if (clientFlag and blockFlag == 0) return 0 + val pos = buffer.writerIndex() + return try { + pOnDemandData(buffer, localIndex, block, observerIndex) + blockFlag + } catch (e: Exception) { + buffer.writerIndex(pos) + logger.error(e) { + "Unable to put on demand mask data for $block" + } + 0 + } + } + + @Suppress("unused") + private companion object { + private val logger = InlineLogger() + private const val VISIBLE_OPS: Int = 0x1 + private const val SEQUENCE: Int = 0x2 + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntitySequenceEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntitySequenceEncoder.kt new file mode 100644 index 000000000..31777069d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntitySequenceEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.worldentity.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence + +public class WorldEntitySequenceEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: Sequence, + ): JagByteBuf { + val buffer = + alloc + .buffer(3, 3) + .toJagByteBuf() + buffer.p2Alt2(extendedInfo.id.toInt()) + buffer.p1Alt3(extendedInfo.delay.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityVisibleOpsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityVisibleOpsEncoder.kt new file mode 100644 index 000000000..4d7254100 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/worldentity/extendedinfo/WorldEntityVisibleOpsEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.worldentity.extendedinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps + +public class WorldEntityVisibleOpsEncoder : PrecomputedExtendedInfoEncoder { + override fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: VisibleOps, + ): JagByteBuf { + val buffer = + alloc + .buffer(1, 1) + .toJagByteBuf() + buffer.p1(extendedInfo.ops.toInt()) + return buffer + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/DesktopUpdateZonePartialEnclosedEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/DesktopUpdateZonePartialEnclosedEncoder.kt new file mode 100644 index 000000000..a7f004e62 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/DesktopUpdateZonePartialEnclosedEncoder.kt @@ -0,0 +1,167 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.header + +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import io.netty.util.ReferenceCountUtil +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocAddChangeV2Encoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocDelEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocMergeEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.MapAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.MapProjAnimV2Encoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjAddEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjCountEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjCustomiseEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjDelEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjEnabledOpsEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.ObjUncustomiseEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.SoundAreaEncoder +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.header.UpdateZonePartialEnclosed +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder +import net.rsprot.protocol.message.ZoneProt +import net.rsprot.protocol.message.codec.MessageEncoder +import net.rsprot.protocol.message.codec.UpdateZonePartialEnclosedCache +import kotlin.math.min + +public class DesktopUpdateZonePartialEnclosedEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_ZONE_PARTIAL_ENCLOSED + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateZonePartialEnclosed, + ) { + buffer.p1(message.zoneX) + buffer.p1Alt3(message.zoneZ) + buffer.p1Alt2(message.level) + // Special handling in our Netty encoder for the payload + // buffer.buffer.writeBytes( + // message.payload, + // message.payload.readerIndex(), + // message.payload.readableBytes(), + // ) + } + + public companion object : UpdateZonePartialEnclosedCache { + private const val MAX_PARTIAL_ENCLOSED_SIZE = 40_000 - 3 + + /** + * Builds a cache of a given zone's list of zone prots. + * This is intended so the server only requests one cache per zone per game cycle, + * rather than re-building the same buffer N times, where N is the number of players + * observing the zone. With this in mind however, zone prots which are player-specific, + * such as OBJ_ADD cannot be grouped together and must be sent separately, as they also + * are in OldSchool RuneScape. + * @param allocator the byte buffer allocator used for the cached buffer. + * Note that it is the server's responsibility to release the buffer once the cycle has ended. + * The individual writes of [UpdateZonePartialEnclosed] do not modify the reference count + * in any way. + * @param messages the list of zone prot messages to be encoded. + */ + override fun buildCache( + allocator: ByteBufAllocator, + messages: Collection, + ): ByteBuf { + val buffer = + allocator + .buffer( + min(IndexedZoneProtEncoder.maxZoneProtSize * messages.size, MAX_PARTIAL_ENCLOSED_SIZE), + MAX_PARTIAL_ENCLOSED_SIZE, + ).toJagByteBuf() + try { + for (message in messages) { + val indexedEncoder = IndexedZoneProtEncoder.indexedEncoders[message.protId] + buffer.p1(indexedEncoder.ordinal) + encodeMessage( + buffer, + message, + indexedEncoder.encoder, + ) + } + } catch (t: Throwable) { + ReferenceCountUtil.safeRelease(buffer.buffer) + throw t + } + return buffer.buffer + } + + /** + * Encodes the [message] into the [buffer] using the [encoder] as the encoder for it. + * @param buffer the buffer to encode into + * @param message the message to be encoded + * @param encoder the encoder to use for encoding the message. + * Note that the type of the encoder is not compile-time known as we acquire it dynamically + * based on the message itself. + */ + private fun encodeMessage( + buffer: JagByteBuf, + message: T, + encoder: ZoneProtEncoder<*>, + ) { + @Suppress("UNCHECKED_CAST") + encoder as ZoneProtEncoder + encoder.encode(buffer, message) + } + + /** + * Zone prot encoders here are used specifically by the [UpdateZonePartialEnclosed] + * packet, as this packet has its own sub-system of the zone prots, with the ability + * to send a batch of zone packets in one go with its own internal indexing. + * + * WARNING: This enum's order MUST match the order in the client, as the + * [IndexedZoneProtEncoder.ordinal] function is used for indexing! + * + * @property protId the respective [ZoneProt.protId] of each message, used for + * quick indexing of respective messages. + * @property encoder the zone prot encoder responsible for encoding the respective message + * into a byte buffer. + */ + private enum class IndexedZoneProtEncoder( + private val protId: Int, + val encoder: ZoneProtEncoder<*>, + ) { + OBJ_DEL(OldSchoolZoneProt.OBJ_DEL, ObjDelEncoder()), + MAP_ANIM(OldSchoolZoneProt.MAP_ANIM, MapAnimEncoder()), + OBJ_COUNT(OldSchoolZoneProt.OBJ_COUNT, ObjCountEncoder()), + LOC_DEL(OldSchoolZoneProt.LOC_DEL, LocDelEncoder()), + MAP_PROJANIM_V2(OldSchoolZoneProt.MAP_PROJANIM_V2, MapProjAnimV2Encoder()), + OBJ_ENABLED_OPS(OldSchoolZoneProt.OBJ_ENABLED_OPS, ObjEnabledOpsEncoder()), + OBJ_ADD(OldSchoolZoneProt.OBJ_ADD, ObjAddEncoder()), + OBJ_CUSTOMISE(OldSchoolZoneProt.OBJ_CUSTOMISE, ObjCustomiseEncoder()), + LOC_ANIM(OldSchoolZoneProt.LOC_ANIM, LocAnimEncoder()), + OBJ_UNCUSTOMISE(OldSchoolZoneProt.OBJ_UNCUSTOMISE, ObjUncustomiseEncoder()), + LOC_MERGE(OldSchoolZoneProt.LOC_MERGE, LocMergeEncoder()), + LOC_ADD_CHANGE_V2(OldSchoolZoneProt.LOC_ADD_CHANGE_V2, LocAddChangeV2Encoder()), + SOUND_AREA(OldSchoolZoneProt.SOUND_AREA, SoundAreaEncoder()), + ; + + companion object { + /** + * The maximum possible size of a single zone prot. + * This constant is used to determine the maximum initial possible buffer capacity. + */ + val maxZoneProtSize = + entries.maxOf { + it.encoder.prot.size + } + + /** + * The zone prot encoders indexed by their prot ids, allowing for fast access based + * on the respective [ZoneProt.protId] through the array. + */ + val indexedEncoders = + Array(entries.size) { index -> + entries.first { prot -> + index == prot.protId + } + } + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZoneFullFollowsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZoneFullFollowsEncoder.kt new file mode 100644 index 000000000..f3b607d01 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZoneFullFollowsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.header + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.header.UpdateZoneFullFollows +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateZoneFullFollowsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_ZONE_FULL_FOLLOWS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateZoneFullFollows, + ) { + buffer.p1Alt2(message.level) + buffer.p1(message.zoneZ) + buffer.p1Alt2(message.zoneX) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZonePartialFollowsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZonePartialFollowsEncoder.kt new file mode 100644 index 000000000..00bcf1956 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/header/UpdateZonePartialFollowsEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.header + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.header.UpdateZonePartialFollows +import net.rsprot.protocol.message.codec.MessageEncoder + +public class UpdateZonePartialFollowsEncoder : MessageEncoder { + override val prot: ServerProt = GameServerProt.UPDATE_ZONE_PARTIAL_FOLLOWS + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: UpdateZonePartialFollows, + ) { + buffer.p1Alt2(message.level) + buffer.p1Alt1(message.zoneX) + buffer.p1Alt1(message.zoneZ) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAddChangeV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAddChangeV2Encoder.kt new file mode 100644 index 000000000..9c709c052 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAddChangeV2Encoder.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.LocAddChangeV2 +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class LocAddChangeV2Encoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.LOC_ADD_CHANGE_V2 + + override fun encode( + buffer: JagByteBuf, + message: LocAddChangeV2, + ) { + // The function at the bottom of the LOC_ADD_CHANGE has a consistent order, + // making it easy to identify all the properties of this packet: + // loc_add_change_del(world, level, x, z, layer, id, shape, rotation, opFlags, ops, 0, -1); + buffer.p1Alt1(message.opFlags.toInt()) + buffer.p1Alt1(message.locPropertiesPacked) + val ops = message.ops + val opCount = ops?.size ?: 0 + buffer.p1Alt3(opCount) + if (!ops.isNullOrEmpty()) { + for ((key, value) in ops) { + buffer.p1(key.toInt() - 1) + buffer.pjstr(value) + } + } + buffer.p1(message.coordInZonePacked) + buffer.p2Alt1(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAnimEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAnimEncoder.kt new file mode 100644 index 000000000..b72eff38c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocAnimEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.LocAnim +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class LocAnimEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.LOC_ANIM + + override fun encode( + buffer: JagByteBuf, + message: LocAnim, + ) { + // The function at the bottom of the LOC_ANIM has a consistent order, + // making it easy to identify all the properties of this packet: + // loc_anim(level, x, z, shape, rotation, layer, id) + buffer.p1Alt3(message.locPropertiesPacked) + buffer.p2Alt3(message.id) + buffer.p1Alt1(message.coordInZonePacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocDelEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocDelEncoder.kt new file mode 100644 index 000000000..05782ca24 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocDelEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.LocDel +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class LocDelEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.LOC_DEL + + override fun encode( + buffer: JagByteBuf, + message: LocDel, + ) { + // The function at the bottom of the LOC_DEL has a consistent order, + // making it easy to identify all the properties of this packet: + // loc_add_change_del(world, level, x, z, layer, -1, shape, rotation, 31, null, 0, -1) + buffer.p1Alt1(message.coordInZonePacked) + buffer.p1Alt1(message.locPropertiesPacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocMergeEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocMergeEncoder.kt new file mode 100644 index 000000000..339d8a052 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/LocMergeEncoder.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.LocMerge +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class LocMergeEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.LOC_MERGE + + override fun encode( + buffer: JagByteBuf, + message: LocMerge, + ) { + // The function at the bottom of the LOC_MERGE has a consistent order, + // making it easy to identify all the properties of this packet: + // loc_merge(level, x, z, shape, rotation, layer, id, start, end, minX, minZ, maxX, maxZ, player) + buffer.p1Alt3(message.minZ) + buffer.p1Alt2(message.maxX) + buffer.p1Alt1(message.maxZ) + buffer.p2(message.end) + buffer.p2Alt1(message.index) + buffer.p2Alt1(message.start) + buffer.p1(message.coordInZonePacked) + buffer.p2Alt3(message.id) + buffer.p1Alt3(message.minX) + buffer.p1Alt3(message.locPropertiesPacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapAnimEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapAnimEncoder.kt new file mode 100644 index 000000000..7bea6f3a3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapAnimEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.MapAnim +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class MapAnimEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.MAP_ANIM + + override fun encode( + buffer: JagByteBuf, + message: MapAnim, + ) { + // While MAP_ANIM does not have a common function like the rest, + // the constructor for the SpotAnimation object itself has the following order: + // SpotAnimation(world, id, level, fineX, fineZ, getGroundHeight(fineX, fineZ, level) - height, delay, cycle) + buffer.p1(message.height) + buffer.p2Alt1(message.delay) + buffer.p2(message.id) + buffer.p1Alt3(message.coordInZonePacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapProjAnimV2Encoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapProjAnimV2Encoder.kt new file mode 100644 index 000000000..28e78f12a --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/MapProjAnimV2Encoder.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.MapProjAnimV2 +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class MapProjAnimV2Encoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.MAP_PROJANIM_V2 + + override fun encode( + buffer: JagByteBuf, + message: MapProjAnimV2, + ) { + // The constructor at the bottom of the MAP_PROJANIM has a consistent order, + // making it easy to identify all the properties of this packet: + // ClientProj( + // startLevel, startX, startZ, startHeight, sourceIndex, + // endLevel, endX, endZ, endHeight, targetIndex, + // id, startTime, endTime, angle, progress) + buffer.p2Alt1(message.startTime) + buffer.p3Alt2(message.targetIndex) + buffer.p3Alt3(message.sourceIndex) + buffer.p1(message.coordInZonePacked) + buffer.p2Alt3(message.progress) + buffer.p2Alt2(message.endTime) + buffer.p2Alt1(message.endHeight) + buffer.p2Alt1(message.id) + buffer.p1(message.angle) + buffer.p2(message.startHeight) + buffer.p4Alt1(message.end.packed) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjAddEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjAddEncoder.kt new file mode 100644 index 000000000..42e779865 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjAddEncoder.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjAdd +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjAddEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_ADD + + override fun encode( + buffer: JagByteBuf, + message: ObjAdd, + ) { + // The function at the bottom of the OBJ_ADD has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_add(level, x, z, id, quantity, opFlags, + // timeUntilPublic, timeUntilDespawn, ownershipType, neverBecomesPublic) + buffer.p1Alt3(message.opFlags.toInt()) + buffer.p2(message.id) + buffer.p2Alt1(message.timeUntilPublic) + buffer.p1Alt2(if (message.neverBecomesPublic) 1 else 0) + buffer.p4Alt1(message.quantity) + buffer.p2Alt1(message.timeUntilDespawn) + buffer.p1Alt1(message.ownershipType) + buffer.p1Alt3(message.coordInZonePacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCountEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCountEncoder.kt new file mode 100644 index 000000000..e78929191 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCountEncoder.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjCount +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjCountEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_COUNT + + override fun encode( + buffer: JagByteBuf, + message: ObjCount, + ) { + // The function at the bottom of the OBJ_COUNT has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_count(level, x, z, id, oldQuantity, newQuantity) + buffer.p4(message.newQuantity) + buffer.p4Alt1(message.oldQuantity) + buffer.p1(message.coordInZonePacked) + buffer.p2Alt1(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCustomiseEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCustomiseEncoder.kt new file mode 100644 index 000000000..e8446668d --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjCustomiseEncoder.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjCustomise +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjCustomiseEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_CUSTOMISE + + override fun encode( + buffer: JagByteBuf, + message: ObjCustomise, + ) { + // The function at the bottom of the OBJ_CUSTOMISE has a consistent order, + // making it easy to identify all the properties of this packet: + // objCustomise(level, x, z, id, count, recol, recolIndex, retex, retexIndex, model); + buffer.p2(message.retexIndex) + buffer.p2Alt1(message.retex) + buffer.p2(message.model) + buffer.p2Alt2(message.recol) + buffer.p1Alt3(message.coordInZonePacked) + buffer.p2Alt1(message.id) + buffer.p4(message.quantity) + buffer.p2(message.recolIndex) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjDelEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjDelEncoder.kt new file mode 100644 index 000000000..ae732e7d2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjDelEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjDel +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjDelEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_DEL + + override fun encode( + buffer: JagByteBuf, + message: ObjDel, + ) { + // The function at the bottom of the OBJ_DEL has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_del(level, x, z, id, quantity) + buffer.p1Alt3(message.coordInZonePacked) + buffer.p4Alt1(message.quantity) + buffer.p2Alt1(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjEnabledOpsEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjEnabledOpsEncoder.kt new file mode 100644 index 000000000..bced27631 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjEnabledOpsEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjEnabledOps +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjEnabledOpsEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_ENABLED_OPS + + override fun encode( + buffer: JagByteBuf, + message: ObjEnabledOps, + ) { + // The function at the bottom of the OBJ_OPFILTER has a consistent order, + // making it easy to identify all the properties of this packet: + // obj_opfilter(level, x, z, id, opFlags) + buffer.p1Alt2(message.opFlags.toInt()) + buffer.p1(message.coordInZonePacked) + buffer.p2Alt3(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjUncustomiseEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjUncustomiseEncoder.kt new file mode 100644 index 000000000..f094ed297 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/ObjUncustomiseEncoder.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.ObjUncustomise +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class ObjUncustomiseEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.OBJ_UNCUSTOMISE + + override fun encode( + buffer: JagByteBuf, + message: ObjUncustomise, + ) { + // The function at the bottom of the OBJ_CUSTOMISE has a consistent order, + // making it easy to identify all the properties of this packet: + // objUncustomise(level, x, z, id, count); + buffer.p4Alt3(message.quantity) + buffer.p2(message.id) + buffer.p1Alt3(message.coordInZonePacked) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/SoundAreaEncoder.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/SoundAreaEncoder.kt new file mode 100644 index 000000000..ee2a210bb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/codec/zone/payload/SoundAreaEncoder.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.game.outgoing.prot.GameServerProt +import net.rsprot.protocol.game.outgoing.zone.payload.SoundArea +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.ZoneProtEncoder + +public class SoundAreaEncoder : ZoneProtEncoder { + override val prot: ServerProt = GameServerProt.SOUND_AREA + + override fun encode( + buffer: JagByteBuf, + message: SoundArea, + ) { + // Sound area function can be found at the bottom as: + // SoundList.playAreaSound(activeWorld.id, id, x, z, range, dropOffRange, loops, delay); + buffer.p1Alt2(message.loops) + buffer.p1(message.dropOffRange) + buffer.p1Alt1(message.delay) + buffer.p1Alt3(message.coordInZonePacked) + buffer.p1Alt1(message.range) + buffer.p2Alt3(message.id) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/DesktopGameMessageEncoderRepository.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/DesktopGameMessageEncoderRepository.kt new file mode 100644 index 000000000..3eae6375c --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/DesktopGameMessageEncoderRepository.kt @@ -0,0 +1,321 @@ +package net.rsprot.protocol.game.outgoing.prot + +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.ProtRepository +import net.rsprot.protocol.game.outgoing.codec.camera.CamLookAtEasedCoordV1Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamLookAtEasedCoordV2Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamLookAtV1Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamLookAtV2Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamModeEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToArcV1Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToArcV2Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToCyclesV1Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToCyclesV2Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToV1Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamMoveToV2Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamResetEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamRotateByEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamRotateToEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamShakeEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamSmoothResetEncoder +import net.rsprot.protocol.game.outgoing.codec.camera.CamTargetV3Encoder +import net.rsprot.protocol.game.outgoing.codec.camera.OculusSyncEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.ClanChannelDeltaEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.ClanChannelFullEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.ClanSettingsDeltaEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.ClanSettingsFullEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.MessageClanChannelEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.MessageClanChannelSystemEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.VarClanDisableEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.VarClanEnableEncoder +import net.rsprot.protocol.game.outgoing.codec.clan.VarClanEncoder +import net.rsprot.protocol.game.outgoing.codec.friendchat.MessageFriendChannelEncoder +import net.rsprot.protocol.game.outgoing.codec.friendchat.UpdateFriendChatChannelFullV2Encoder +import net.rsprot.protocol.game.outgoing.codec.friendchat.UpdateFriendChatChannelSingleUserEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfClearInvEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfCloseSubEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfMoveSubEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfOpenSubEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfOpenTopEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfResyncV2Encoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetAngleEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetColourEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetEventsV2Encoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetHideEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetModelEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetNpcHeadActiveEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetNpcHeadEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetObjectEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPlayerHeadEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPlayerModelBaseColourEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPlayerModelBodyTypeEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPlayerModelObjEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPlayerModelSelfEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetPositionEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetRotateSpeedEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetScrollPosEncoder +import net.rsprot.protocol.game.outgoing.codec.interfaces.IfSetTextEncoder +import net.rsprot.protocol.game.outgoing.codec.inv.UpdateInvFullEncoder +import net.rsprot.protocol.game.outgoing.codec.inv.UpdateInvPartialEncoder +import net.rsprot.protocol.game.outgoing.codec.inv.UpdateInvStopTransmitEncoder +import net.rsprot.protocol.game.outgoing.codec.logout.LogoutEncoder +import net.rsprot.protocol.game.outgoing.codec.logout.LogoutTransferEncoder +import net.rsprot.protocol.game.outgoing.codec.logout.LogoutWithReasonEncoder +import net.rsprot.protocol.game.outgoing.codec.map.RebuildNormalEncoder +import net.rsprot.protocol.game.outgoing.codec.map.RebuildRegionEncoder +import net.rsprot.protocol.game.outgoing.codec.map.RebuildWorldEntityV2Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.HideLocOpsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.HideNpcOpsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.HideObjOpsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.HintArrowEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.HiscoreReplyEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.MinimapToggleEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.PacketGroupStartEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.ReflectionCheckerEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.ResetAnimsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.ResetInteractionModeEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.SendPingEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.ServerTickEndEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.SetHeatmapEnabledEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.SetInteractionModeEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.SiteSettingsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.UpdateRebootTimerV1Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.UpdateRebootTimerV2Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.UpdateUid192Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.UrlOpenEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.client.ZBufEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.AccountFlagsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.ChatFilterSettingsEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.ChatFilterSettingsPrivateChatEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.MessageGameEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.RunClientScriptEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.SetMapFlagV1Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.SetMapFlagV2Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.SetPlayerOpEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.TriggerOnDialogAbortEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.UpdateRunEnergyEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.UpdateRunWeightEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.UpdateStatV2Encoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.UpdateStockMarketSlotEncoder +import net.rsprot.protocol.game.outgoing.codec.misc.player.UpdateTradingPostEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.NpcInfoLargeV5Encoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.NpcInfoSmallV5Encoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.SetNpcUpdateOriginEncoder +import net.rsprot.protocol.game.outgoing.codec.playerinfo.PlayerInfoEncoder +import net.rsprot.protocol.game.outgoing.codec.social.FriendListLoadedEncoder +import net.rsprot.protocol.game.outgoing.codec.social.MessagePrivateEchoEncoder +import net.rsprot.protocol.game.outgoing.codec.social.MessagePrivateEncoder +import net.rsprot.protocol.game.outgoing.codec.social.UpdateFriendListEncoder +import net.rsprot.protocol.game.outgoing.codec.social.UpdateIgnoreListEncoder +import net.rsprot.protocol.game.outgoing.codec.sound.MidiJingleEncoder +import net.rsprot.protocol.game.outgoing.codec.sound.MidiSongStopEncoder +import net.rsprot.protocol.game.outgoing.codec.sound.MidiSongV2Encoder +import net.rsprot.protocol.game.outgoing.codec.sound.MidiSongWithSecondaryEncoder +import net.rsprot.protocol.game.outgoing.codec.sound.MidiSwapEncoder +import net.rsprot.protocol.game.outgoing.codec.sound.SynthSoundEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.LocAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.MapAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.NpcAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.NpcHeadIconSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.NpcSpotAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjAddSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjCountSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjCustomiseSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjDelSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjEnabledOpsSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ObjUncustomiseSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.PlayerAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.PlayerSpotAnimSpecificEncoder +import net.rsprot.protocol.game.outgoing.codec.specific.ProjAnimSpecificV4Encoder +import net.rsprot.protocol.game.outgoing.codec.varp.VarpLargeEncoder +import net.rsprot.protocol.game.outgoing.codec.varp.VarpResetEncoder +import net.rsprot.protocol.game.outgoing.codec.varp.VarpSmallEncoder +import net.rsprot.protocol.game.outgoing.codec.varp.VarpSyncEncoder +import net.rsprot.protocol.game.outgoing.codec.worldentity.SetActiveWorldV2Encoder +import net.rsprot.protocol.game.outgoing.codec.worldentity.WorldEntityInfoV7Encoder +import net.rsprot.protocol.game.outgoing.codec.zone.header.DesktopUpdateZonePartialEnclosedEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.header.UpdateZoneFullFollowsEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.header.UpdateZonePartialFollowsEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocAddChangeV2Encoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocDelEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.LocMergeEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.MapAnimEncoder +import net.rsprot.protocol.game.outgoing.codec.zone.payload.SoundAreaEncoder +import net.rsprot.protocol.game.outgoing.map.RebuildLogin +import net.rsprot.protocol.game.outgoing.map.RebuildNormal +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepositoryBuilder + +public object DesktopGameMessageEncoderRepository { + @ExperimentalStdlibApi + public fun build(huffmanCodecProvider: HuffmanCodecProvider): MessageEncoderRepository { + val protRepository = ProtRepository.of() + val builder = + MessageEncoderRepositoryBuilder( + protRepository, + ).apply { + bind(IfResyncV2Encoder()) + bind(IfOpenTopEncoder()) + bind(IfOpenSubEncoder()) + bind(IfCloseSubEncoder()) + bind(IfMoveSubEncoder()) + bind(IfClearInvEncoder()) + bind(IfSetEventsV2Encoder()) + bind(IfSetPositionEncoder()) + bind(IfSetScrollPosEncoder()) + bind(IfSetRotateSpeedEncoder()) + bind(IfSetTextEncoder()) + bind(IfSetHideEncoder()) + bind(IfSetAngleEncoder()) + bind(IfSetObjectEncoder()) + bind(IfSetColourEncoder()) + bind(IfSetAnimEncoder()) + bind(IfSetNpcHeadEncoder()) + bind(IfSetNpcHeadActiveEncoder()) + bind(IfSetPlayerHeadEncoder()) + bind(IfSetModelEncoder()) + bind(IfSetPlayerModelBaseColourEncoder()) + bind(IfSetPlayerModelBodyTypeEncoder()) + bind(IfSetPlayerModelObjEncoder()) + bind(IfSetPlayerModelSelfEncoder()) + + bind(MidiSongV2Encoder()) + bind(MidiSongWithSecondaryEncoder()) + bind(MidiSwapEncoder()) + bind(MidiSongStopEncoder()) + bind(MidiJingleEncoder()) + bind(SynthSoundEncoder()) + + bind(UpdateZoneFullFollowsEncoder()) + bind(UpdateZonePartialFollowsEncoder()) + bind(DesktopUpdateZonePartialEnclosedEncoder()) + + bind(LocAddChangeV2Encoder()) + bind(LocDelEncoder()) + bind(LocAnimEncoder()) + bind(LocMergeEncoder()) + bind(MapAnimEncoder()) + bind(SoundAreaEncoder()) + + bind(ProjAnimSpecificV4Encoder()) + bind(MapAnimSpecificEncoder()) + bind(LocAnimSpecificEncoder()) + bind(NpcHeadIconSpecificEncoder()) + bind(NpcSpotAnimSpecificEncoder()) + bind(NpcAnimSpecificEncoder()) + bind(PlayerAnimSpecificEncoder()) + bind(PlayerSpotAnimSpecificEncoder()) + bind(ObjAddSpecificEncoder()) + bind(ObjDelSpecificEncoder()) + bind(ObjCountSpecificEncoder()) + bind(ObjEnabledOpsSpecificEncoder()) + bind(ObjCustomiseSpecificEncoder()) + bind(ObjUncustomiseSpecificEncoder()) + + bind(PlayerInfoEncoder()) + bind(NpcInfoSmallV5Encoder()) + bind(NpcInfoLargeV5Encoder()) + bind(SetNpcUpdateOriginEncoder()) + + bind(SetActiveWorldV2Encoder()) + bind(WorldEntityInfoV7Encoder()) + + bindWithAlts(RebuildNormalEncoder(), RebuildLogin::class.java, RebuildNormal::class.java) + bind(RebuildRegionEncoder()) + bind(RebuildWorldEntityV2Encoder()) + + bind(VarpSmallEncoder()) + bind(VarpLargeEncoder()) + bind(VarpResetEncoder()) + bind(VarpSyncEncoder()) + + bind(CamShakeEncoder()) + bind(CamResetEncoder()) + bind(CamSmoothResetEncoder()) + bind(CamMoveToV1Encoder()) + bind(CamMoveToV2Encoder()) + bind(CamMoveToCyclesV1Encoder()) + bind(CamMoveToCyclesV2Encoder()) + bind(CamMoveToArcV1Encoder()) + bind(CamMoveToArcV2Encoder()) + bind(CamLookAtV1Encoder()) + bind(CamLookAtV2Encoder()) + bind(CamLookAtEasedCoordV1Encoder()) + bind(CamLookAtEasedCoordV2Encoder()) + bind(CamRotateByEncoder()) + bind(CamRotateToEncoder()) + bind(CamModeEncoder()) + bind(CamTargetV3Encoder()) + bind(OculusSyncEncoder()) + + bind(UpdateInvFullEncoder()) + bind(UpdateInvPartialEncoder()) + bind(UpdateInvStopTransmitEncoder()) + + bind(MessagePrivateEncoder(huffmanCodecProvider)) + bind(MessagePrivateEchoEncoder(huffmanCodecProvider)) + bind(FriendListLoadedEncoder()) + bind(UpdateFriendListEncoder()) + bind(UpdateIgnoreListEncoder()) + + bind(UpdateFriendChatChannelFullV2Encoder()) + bind(UpdateFriendChatChannelSingleUserEncoder()) + bind(MessageFriendChannelEncoder(huffmanCodecProvider)) + + bind(VarClanEncoder()) + bind(VarClanEnableEncoder()) + bind(VarClanDisableEncoder()) + bind(ClanChannelFullEncoder()) + bind(ClanChannelDeltaEncoder()) + bind(ClanSettingsFullEncoder()) + bind(ClanSettingsDeltaEncoder()) + bind(MessageClanChannelEncoder(huffmanCodecProvider)) + bind(MessageClanChannelSystemEncoder(huffmanCodecProvider)) + + bind(LogoutEncoder()) + bind(LogoutWithReasonEncoder()) + bind(LogoutTransferEncoder()) + + bind(UpdateRunWeightEncoder()) + bind(UpdateRunEnergyEncoder()) + bind(SetMapFlagV1Encoder()) + bind(SetMapFlagV2Encoder()) + bind(SetPlayerOpEncoder()) + bind(UpdateStatV2Encoder()) + + bind(RunClientScriptEncoder()) + bind(TriggerOnDialogAbortEncoder()) + bind(MessageGameEncoder()) + bind(ChatFilterSettingsEncoder()) + bind(ChatFilterSettingsPrivateChatEncoder()) + bind(UpdateTradingPostEncoder()) + bind(UpdateStockMarketSlotEncoder()) + bind(AccountFlagsEncoder()) + + bind(HintArrowEncoder()) + bind(ResetAnimsEncoder()) + bind(UpdateRebootTimerV1Encoder()) + bind(UpdateRebootTimerV2Encoder()) + bind(SetHeatmapEnabledEncoder()) + bind(MinimapToggleEncoder()) + bind(ServerTickEndEncoder()) + bind(HideNpcOpsEncoder()) + bind(HideObjOpsEncoder()) + bind(HideLocOpsEncoder()) + bind(SetInteractionModeEncoder()) + bind(ResetInteractionModeEncoder()) + bind(PacketGroupStartEncoder()) + bind(ZBufEncoder()) + + bind(UrlOpenEncoder()) + bind(SiteSettingsEncoder()) + bind(UpdateUid192Encoder()) + bind(ReflectionCheckerEncoder()) + bind(SendPingEncoder()) + bind(HiscoreReplyEncoder()) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProt.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProt.kt new file mode 100644 index 000000000..8e0063d09 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProt.kt @@ -0,0 +1,202 @@ +package net.rsprot.protocol.game.outgoing.prot + +import net.rsprot.protocol.Prot +import net.rsprot.protocol.ServerProt + +public enum class GameServerProt( + override val opcode: Int, + override val size: Int, +) : ServerProt { + // Interface related packets + IF_RESYNC_V2(GameServerProtId.IF_RESYNC_V2, Prot.VAR_SHORT), + IF_OPENTOP(GameServerProtId.IF_OPENTOP, 2), + IF_OPENSUB(GameServerProtId.IF_OPENSUB, 7), + IF_CLOSESUB(GameServerProtId.IF_CLOSESUB, 4), + IF_MOVESUB(GameServerProtId.IF_MOVESUB, 8), + IF_CLEARINV(GameServerProtId.IF_CLEARINV, 4), + + IF_SETEVENTS_V2(GameServerProtId.IF_SETEVENTS_V2, 16), + IF_SETPOSITION(GameServerProtId.IF_SETPOSITION, 8), + IF_SETSCROLLPOS(GameServerProtId.IF_SETSCROLLPOS, 6), + IF_SETROTATESPEED(GameServerProtId.IF_SETROTATESPEED, 8), + IF_SETTEXT(GameServerProtId.IF_SETTEXT, Prot.VAR_SHORT), + IF_SETHIDE(GameServerProtId.IF_SETHIDE, 5), + IF_SETANGLE(GameServerProtId.IF_SETANGLE, 10), + IF_SETOBJECT(GameServerProtId.IF_SETOBJECT, 10), + IF_SETCOLOUR(GameServerProtId.IF_SETCOLOUR, 6), + IF_SETANIM(GameServerProtId.IF_SETANIM, 6), + IF_SETNPCHEAD(GameServerProtId.IF_SETNPCHEAD, 6), + IF_SETNPCHEAD_ACTIVE(GameServerProtId.IF_SETNPCHEAD_ACTIVE, 6), + IF_SETPLAYERHEAD(GameServerProtId.IF_SETPLAYERHEAD, 4), + IF_SETMODEL(GameServerProtId.IF_SETMODEL, 6), + IF_SETPLAYERMODEL_BASECOLOUR(GameServerProtId.IF_SETPLAYERMODEL_BASECOLOUR, 6), + IF_SETPLAYERMODEL_BODYTYPE(GameServerProtId.IF_SETPLAYERMODEL_BODYTYPE, 5), + IF_SETPLAYERMODEL_OBJ(GameServerProtId.IF_SETPLAYERMODEL_OBJ, 8), + IF_SETPLAYERMODEL_SELF(GameServerProtId.IF_SETPLAYERMODEL_SELF, 5), + + // Music-system related packets (excl. zone ones) + MIDI_SONG_V2(GameServerProtId.MIDI_SONG_V2, 10), + MIDI_SONG_WITHSECONDARY(GameServerProtId.MIDI_SONG_WITHSECONDARY, 12), + MIDI_SWAP(GameServerProtId.MIDI_SWAP, 8), + MIDI_SONG_STOP(GameServerProtId.MIDI_SONG_STOP, 4), + MIDI_JINGLE(GameServerProtId.MIDI_JINGLE, 5), + SYNTH_SOUND(GameServerProtId.SYNTH_SOUND, 5), + + // Zone header packets + UPDATE_ZONE_FULL_FOLLOWS(GameServerProtId.UPDATE_ZONE_FULL_FOLLOWS, 3), + UPDATE_ZONE_PARTIAL_FOLLOWS(GameServerProtId.UPDATE_ZONE_PARTIAL_FOLLOWS, 3), + UPDATE_ZONE_PARTIAL_ENCLOSED(GameServerProtId.UPDATE_ZONE_PARTIAL_ENCLOSED, Prot.VAR_SHORT), + + // Zone payload packets + LOC_ADD_CHANGE_V2(GameServerProtId.LOC_ADD_CHANGE_V2, -2), + LOC_DEL(GameServerProtId.LOC_DEL, 2), + LOC_ANIM(GameServerProtId.LOC_ANIM, 4), + LOC_MERGE(GameServerProtId.LOC_MERGE, 14), + OBJ_ADD(-1, 14), + OBJ_DEL(-1, 7), + OBJ_COUNT(-1, 11), + OBJ_ENABLED_OPS(-1, 4), + OBJ_CUSTOMISE(-1, 17), + OBJ_UNCUSTOMISE(-1, 7), + MAP_ANIM(GameServerProtId.MAP_ANIM, 6), + + // MAP_PROJANIM_V2 has no packet of its own. It can only be transmitted via the partial enclosed packet. + MAP_PROJANIM_V2(-1, 24), + SOUND_AREA(GameServerProtId.SOUND_AREA, 7), + + // Specific packets + PROJANIM_SPECIFIC_V4(GameServerProtId.PROJANIM_SPECIFIC_V4, 27), + MAP_ANIM_SPECIFIC(GameServerProtId.MAP_ANIM_SPECIFIC, 8), + LOC_ANIM_SPECIFIC(GameServerProtId.LOC_ANIM_SPECIFIC, 6), + NPC_HEADICON_SPECIFIC(GameServerProtId.NPC_HEADICON_SPECIFIC, 9), + NPC_SPOTANIM_SPECIFIC(GameServerProtId.NPC_SPOTANIM_SPECIFIC, 9), + NPC_ANIM_SPECIFIC(GameServerProtId.NPC_ANIM_SPECIFIC, 5), + PLAYER_ANIM_SPECIFIC(GameServerProtId.PLAYER_ANIM_SPECIFIC, 3), + PLAYER_SPOTANIM_SPECIFIC(GameServerProtId.PLAYER_SPOTANIM_SPECIFIC, 9), + OBJ_ADD_SPECIFIC(GameServerProtId.OBJ_ADD_SPECIFIC, 17), + OBJ_DEL_SPECIFIC(GameServerProtId.OBJ_DEL_SPECIFIC, 10), + OBJ_ENABLED_OPS_SPECIFIC(GameServerProtId.OBJ_ENABLED_OPS_SPECIFIC, 7), + OBJ_UNCUSTOMISE_SPECIFIC(GameServerProtId.OBJ_UNCUSTOMISE_SPECIFIC, 10), + OBJ_COUNT_SPECIFIC(GameServerProtId.OBJ_COUNT_SPECIFIC, 14), + OBJ_CUSTOMISE_SPECIFIC(GameServerProtId.OBJ_CUSTOMISE_SPECIFIC, 20), + + // Info packets + PLAYER_INFO(GameServerProtId.PLAYER_INFO, Prot.VAR_SHORT), + NPC_INFO_SMALL_V5(GameServerProtId.NPC_INFO_SMALL_V5, Prot.VAR_SHORT), + NPC_INFO_LARGE_V5(GameServerProtId.NPC_INFO_LARGE_V5, Prot.VAR_SHORT), + + SET_NPC_UPDATE_ORIGIN(GameServerProtId.SET_NPC_UPDATE_ORIGIN, 2), + + // World entity packets + SET_ACTIVE_WORLD_V2(GameServerProtId.SET_ACTIVE_WORLD_V2, 3), + WORLDENTITY_INFO_V7(GameServerProtId.WORLDENTITY_INFO_V7, Prot.VAR_SHORT), + + // Map packets + REBUILD_NORMAL(GameServerProtId.REBUILD_NORMAL, Prot.VAR_SHORT), + REBUILD_REGION(GameServerProtId.REBUILD_REGION, Prot.VAR_SHORT), + REBUILD_WORLDENTITY_V2(GameServerProtId.REBUILD_WORLDENTITY_V2, Prot.VAR_SHORT), + + // Varp packets + VARP_SMALL(GameServerProtId.VARP_SMALL, 3), + VARP_LARGE(GameServerProtId.VARP_LARGE, 6), + VARP_RESET(GameServerProtId.VARP_RESET, 0), + VARP_SYNC(GameServerProtId.VARP_SYNC, 0), + + // Camera packets + CAM_SHAKE(GameServerProtId.CAM_SHAKE, 4), + CAM_RESET(GameServerProtId.CAM_RESET, 0), + CAM_SMOOTHRESET(GameServerProtId.CAM_SMOOTHRESET, 4), + CAM_MOVETO_V1(GameServerProtId.CAM_MOVETO_V1, 6), + CAM_MOVETO_V2(GameServerProtId.CAM_MOVETO_V2, 8), + CAM_MOVETO_CYCLES_V1(GameServerProtId.CAM_MOVETO_CYCLES_V1, 8), + CAM_MOVETO_CYCLES_V2(GameServerProtId.CAM_MOVETO_CYCLES_V2, 10), + CAM_MOVETO_ARC_V1(GameServerProtId.CAM_MOVETO_ARC_V1, 10), + CAM_MOVETO_ARC_V2(GameServerProtId.CAM_MOVETO_ARC_V2, 14), + CAM_LOOKAT_V1(GameServerProtId.CAM_LOOKAT_V1, 6), + CAM_LOOKAT_V2(GameServerProtId.CAM_LOOKAT_V2, 8), + CAM_LOOKAT_EASED_COORD_V1(GameServerProtId.CAM_LOOKAT_EASED_COORD_V1, 7), + CAM_LOOKAT_EASED_COORD_V2(GameServerProtId.CAM_LOOKAT_EASED_COORD_V2, 9), + CAM_ROTATEBY(GameServerProtId.CAM_ROTATEBY, 7), + CAM_ROTATETO(GameServerProtId.CAM_ROTATETO, 7), + CAM_MODE(GameServerProtId.CAM_MODE, 1), + CAM_TARGET_V3(GameServerProtId.CAM_TARGET_V3, 5), + OCULUS_SYNC(GameServerProtId.OCULUS_SYNC, 4), + + // Inventory packets + UPDATE_INV_FULL(GameServerProtId.UPDATE_INV_FULL, Prot.VAR_SHORT), + UPDATE_INV_PARTIAL(GameServerProtId.UPDATE_INV_PARTIAL, Prot.VAR_SHORT), + UPDATE_INV_STOPTRANSMIT(GameServerProtId.UPDATE_INV_STOPTRANSMIT, 2), + + // Social packets + MESSAGE_PRIVATE(GameServerProtId.MESSAGE_PRIVATE, Prot.VAR_SHORT), + MESSAGE_PRIVATE_ECHO(GameServerProtId.MESSAGE_PRIVATE_ECHO, Prot.VAR_SHORT), + FRIENDLIST_LOADED(GameServerProtId.FRIENDLIST_LOADED, 0), + UPDATE_FRIENDLIST(GameServerProtId.UPDATE_FRIENDLIST, Prot.VAR_SHORT), + UPDATE_IGNORELIST(GameServerProtId.UPDATE_IGNORELIST, Prot.VAR_SHORT), + + // Friend chat (old "clans") packets + UPDATE_FRIENDCHAT_CHANNEL_FULL_V2(GameServerProtId.UPDATE_FRIENDCHAT_CHANNEL_FULL_V2, Prot.VAR_SHORT), + UPDATE_FRIENDCHAT_CHANNEL_SINGLEUSER(GameServerProtId.UPDATE_FRIENDCHAT_CHANNEL_SINGLEUSER, Prot.VAR_BYTE), + MESSAGE_FRIENDCHANNEL(GameServerProtId.MESSAGE_FRIENDCHANNEL, Prot.VAR_BYTE), + + // Clan chat packets + VARCLAN(GameServerProtId.VARCLAN, Prot.VAR_BYTE), + VARCLAN_ENABLE(GameServerProtId.VARCLAN_ENABLE, 0), + VARCLAN_DISABLE(GameServerProtId.VARCLAN_DISABLE, 0), + CLANCHANNEL_FULL(GameServerProtId.CLANCHANNEL_FULL, Prot.VAR_SHORT), + CLANCHANNEL_DELTA(GameServerProtId.CLANCHANNEL_DELTA, Prot.VAR_SHORT), + CLANSETTINGS_FULL(GameServerProtId.CLANSETTINGS_FULL, Prot.VAR_SHORT), + CLANSETTINGS_DELTA(GameServerProtId.CLANSETTINGS_DELTA, Prot.VAR_SHORT), + MESSAGE_CLANCHANNEL(GameServerProtId.MESSAGE_CLANCHANNEL, Prot.VAR_BYTE), + MESSAGE_CLANCHANNEL_SYSTEM(GameServerProtId.MESSAGE_CLANCHANNEL_SYSTEM, Prot.VAR_BYTE), + + // Log out packets + LOGOUT(GameServerProtId.LOGOUT, 0), + LOGOUT_WITHREASON(GameServerProtId.LOGOUT_WITHREASON, 1), + LOGOUT_TRANSFER(GameServerProtId.LOGOUT_TRANSFER, Prot.VAR_BYTE), + + // Misc. player state packets + UPDATE_RUNWEIGHT(GameServerProtId.UPDATE_RUNWEIGHT, 2), + UPDATE_RUNENERGY(GameServerProtId.UPDATE_RUNENERGY, 2), + SET_MAP_FLAG_V1(GameServerProtId.SET_MAP_FLAG_V1, 2), + SET_MAP_FLAG_V2(GameServerProtId.SET_MAP_FLAG_V2, 4), + SET_PLAYER_OP(GameServerProtId.SET_PLAYER_OP, Prot.VAR_BYTE), + UPDATE_STAT_V2(GameServerProtId.UPDATE_STAT_V2, 7), + + // Misc. player packets + RUNCLIENTSCRIPT(GameServerProtId.RUNCLIENTSCRIPT, Prot.VAR_SHORT), + TRIGGER_ONDIALOGABORT(GameServerProtId.TRIGGER_ONDIALOGABORT, 0), + MESSAGE_GAME(GameServerProtId.MESSAGE_GAME, Prot.VAR_BYTE), + CHAT_FILTER_SETTINGS(GameServerProtId.CHAT_FILTER_SETTINGS, 2), + CHAT_FILTER_SETTINGS_PRIVATECHAT(GameServerProtId.CHAT_FILTER_SETTINGS_PRIVATECHAT, 1), + UPDATE_TRADINGPOST(GameServerProtId.UPDATE_TRADINGPOST, Prot.VAR_SHORT), + UPDATE_STOCKMARKET_SLOT(GameServerProtId.UPDATE_STOCKMARKET_SLOT, 20), + ACCOUNT_FLAGS(GameServerProtId.ACCOUNT_FLAGS, 8), + + // Misc. client state packets + HINT_ARROW(GameServerProtId.HINT_ARROW, 6), + RESET_ANIMS(GameServerProtId.RESET_ANIMS, 0), + UPDATE_REBOOT_TIMER_V1(GameServerProtId.UPDATE_REBOOT_TIMER_V1, 2), + UPDATE_REBOOT_TIMER_V2(GameServerProtId.UPDATE_REBOOT_TIMER_V2, Prot.VAR_BYTE), + SET_HEATMAP_ENABLED(GameServerProtId.SET_HEATMAP_ENABLED, 1), + MINIMAP_TOGGLE(GameServerProtId.MINIMAP_TOGGLE, 1), + SERVER_TICK_END(GameServerProtId.SERVER_TICK_END, 0), + HIDENPCOPS(GameServerProtId.HIDENPCOPS, 1), + HIDEOBJOPS(GameServerProtId.HIDEOBJOPS, 1), + HIDELOCOPS(GameServerProtId.HIDELOCOPS, 1), + SET_INTERACTION_MODE(GameServerProtId.SET_INTERACTION_MODE, 4), + RESET_INTERACTION_MODE(GameServerProtId.RESET_INTERACTION_MODE, 2), + ZBUF(GameServerProtId.ZBUF, 1), + + // Misc. client packets + URL_OPEN(GameServerProtId.URL_OPEN, Prot.VAR_SHORT), + SITE_SETTINGS(GameServerProtId.SITE_SETTINGS, Prot.VAR_BYTE), + UPDATE_UID192(GameServerProtId.UPDATE_UID192, 28), + REFLECTION_CHECKER(GameServerProtId.REFLECTION_CHECKER, Prot.VAR_SHORT), + SEND_PING(GameServerProtId.SEND_PING, 8), + HISCORE_REPLY(GameServerProtId.HISCORE_REPLY, Prot.VAR_SHORT), + PACKET_GROUP_START(GameServerProtId.PACKET_GROUP_START, 2), + + // Unknown packets + UNKNOWN_STRING(GameServerProtId.UNKNOWN_STRING, Prot.VAR_BYTE), +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProtId.kt b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProtId.kt new file mode 100644 index 000000000..54a66cef5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/main/kotlin/net/rsprot/protocol/game/outgoing/prot/GameServerProtId.kt @@ -0,0 +1,147 @@ +package net.rsprot.protocol.game.outgoing.prot + +internal object GameServerProtId { + const val UPDATE_INV_STOPTRANSMIT = 0 + const val LOC_ADD_CHANGE_V2 = 1 + const val OCULUS_SYNC = 2 + const val NPC_HEADICON_SPECIFIC = 3 + const val CAM_SHAKE = 4 + const val IF_SETMODEL = 5 + const val MAP_ANIM = 6 + const val IF_SETOBJECT = 7 + const val UPDATE_REBOOT_TIMER_V2 = 8 + const val UNKNOWN_STRING = 9 + const val VARCLAN_ENABLE = 10 + const val UPDATE_ZONE_PARTIAL_FOLLOWS = 11 + const val IF_SETHIDE = 12 + const val LOC_ANIM_SPECIFIC = 13 + const val SERVER_TICK_END = 14 + const val IF_SETNPCHEAD = 15 + const val IF_SETPLAYERMODEL_OBJ = 16 + const val REFLECTION_CHECKER = 17 + const val CLANCHANNEL_FULL = 18 + const val NPC_SPOTANIM_SPECIFIC = 19 + const val RESET_ANIMS = 20 + const val IF_OPENSUB = 21 + const val MESSAGE_PRIVATE = 22 + const val SYNTH_SOUND = 23 + const val CAM_MOVETO_ARC_V1 = 24 + const val SET_INTERACTION_MODE = 25 + const val PROJANIM_SPECIFIC_V4 = 26 + const val IF_SETROTATESPEED = 27 + const val UPDATE_INV_PARTIAL = 28 + const val HIDENPCOPS = 29 + const val CLANSETTINGS_DELTA = 30 + const val IF_MOVESUB = 31 + const val VARCLAN = 32 + const val OBJ_ENABLED_OPS_SPECIFIC = 33 + const val REBUILD_NORMAL = 34 + const val MIDI_JINGLE = 35 + const val MESSAGE_FRIENDCHANNEL = 36 + const val VARP_SYNC = 37 + const val SITE_SETTINGS = 38 + const val OBJ_DEL_SPECIFIC = 39 + const val SET_NPC_UPDATE_ORIGIN = 40 + const val VARCLAN_DISABLE = 41 + const val IF_SETPOSITION = 42 + const val CAM_ROTATETO = 43 + const val UPDATE_REBOOT_TIMER_V1 = 44 + const val MESSAGE_PRIVATE_ECHO = 45 + const val LOC_DEL = 46 + const val MESSAGE_GAME = 47 + const val IF_SETANGLE = 48 + const val LOGOUT_TRANSFER = 49 + const val UPDATE_STAT_V2 = 50 + const val IF_CLEARINV = 51 + const val CAM_MOVETO_V1 = 52 + const val CHAT_FILTER_SETTINGS = 53 + const val PLAYER_INFO = 54 + const val MIDI_SWAP = 55 + const val VARP_SMALL = 56 + const val CAM_SMOOTHRESET = 57 + const val FRIENDLIST_LOADED = 58 + const val REBUILD_WORLDENTITY_V2 = 59 + const val UPDATE_TRADINGPOST = 60 + const val CAM_MOVETO_CYCLES_V1 = 61 + const val RESET_INTERACTION_MODE = 62 + const val CHAT_FILTER_SETTINGS_PRIVATECHAT = 63 + const val NPC_INFO_SMALL_V5 = 64 + const val IF_SETSCROLLPOS = 65 + const val IF_SETCOLOUR = 66 + const val IF_OPENTOP = 67 + const val LOGOUT = 68 + const val SET_PLAYER_OP = 69 + const val UPDATE_IGNORELIST = 70 + const val VARP_RESET = 71 + const val REBUILD_REGION = 72 + const val SET_ACTIVE_WORLD_V2 = 73 + const val IF_SETPLAYERMODEL_SELF = 74 + const val CAM_MODE = 75 + const val HINT_ARROW = 76 + const val UPDATE_RUNWEIGHT = 77 + const val TRIGGER_ONDIALOGABORT = 78 + const val LOC_MERGE = 79 + const val SEND_PING = 80 + const val SET_HEATMAP_ENABLED = 81 + const val MESSAGE_CLANCHANNEL_SYSTEM = 82 + const val UPDATE_FRIENDCHAT_CHANNEL_SINGLEUSER = 83 + const val CAM_TARGET_V3 = 84 + const val LOC_ANIM = 85 + const val IF_SETPLAYERMODEL_BASECOLOUR = 86 + const val NPC_INFO_LARGE_V5 = 87 + const val MIDI_SONG_STOP = 88 + const val IF_SETPLAYERHEAD = 89 + const val CAM_RESET = 90 + const val NPC_ANIM_SPECIFIC = 91 + const val IF_SETTEXT = 92 + const val MAP_ANIM_SPECIFIC = 93 + const val CAM_ROTATEBY = 94 + const val VARP_LARGE = 95 + const val IF_CLOSESUB = 96 + const val IF_SETNPCHEAD_ACTIVE = 97 + const val HIDELOCOPS = 98 + const val CAM_LOOKAT_EASED_COORD_V1 = 99 + const val UPDATE_ZONE_FULL_FOLLOWS = 100 + const val UPDATE_ZONE_PARTIAL_ENCLOSED = 101 + const val CLANCHANNEL_DELTA = 102 + const val UPDATE_STOCKMARKET_SLOT = 103 + const val CAM_LOOKAT_V1 = 104 + const val UPDATE_FRIENDCHAT_CHANNEL_FULL_V2 = 105 + const val IF_SETPLAYERMODEL_BODYTYPE = 106 + const val MESSAGE_CLANCHANNEL = 107 + const val PLAYER_SPOTANIM_SPECIFIC = 108 + const val PLAYER_ANIM_SPECIFIC = 109 + const val MIDI_SONG_V2 = 110 + const val UPDATE_RUNENERGY = 111 + const val HISCORE_REPLY = 112 + const val MINIMAP_TOGGLE = 113 + const val URL_OPEN = 114 + const val SOUND_AREA = 115 + const val UPDATE_UID192 = 116 + const val LOGOUT_WITHREASON = 117 + const val RUNCLIENTSCRIPT = 118 + const val IF_SETANIM = 119 + const val SET_MAP_FLAG_V1 = 120 + const val CLANSETTINGS_FULL = 121 + const val OBJ_ADD_SPECIFIC = 122 + const val UPDATE_INV_FULL = 123 + const val HIDEOBJOPS = 124 + const val PACKET_GROUP_START = 125 + const val UPDATE_FRIENDLIST = 126 + const val MIDI_SONG_WITHSECONDARY = 127 + const val CAM_MOVETO_CYCLES_V2 = 128 + const val WORLDENTITY_INFO_V6 = 129 + const val ACCOUNT_FLAGS = 130 + const val IF_SETEVENTS_V2 = 131 + const val WORLDENTITY_INFO_V7 = 132 + const val OBJ_UNCUSTOMISE_SPECIFIC = 133 + const val CAM_MOVETO_V2 = 134 + const val ZBUF = 135 + const val OBJ_COUNT_SPECIFIC = 136 + const val CAM_MOVETO_ARC_V2 = 137 + const val CAM_LOOKAT_EASED_COORD_V2 = 138 + const val SET_MAP_FLAG_V2 = 139 + const val CAM_LOOKAT_V2 = 140 + const val IF_RESYNC_V2 = 141 + const val OBJ_CUSTOMISE_SPECIFIC = 142 +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoClient.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoClient.kt new file mode 100644 index 000000000..b4c28eb5f --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoClient.kt @@ -0,0 +1,309 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.ByteBuf +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.buffer.bitbuffer.toBitBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +@Suppress("MemberVisibilityCanBePrivate") +class NpcInfoClient { + var deletedNpcCount: Int = 0 + var deletedNpcSlot = IntArray(1000) + var cachedNpcs = arrayOfNulls(65536) + var npcSlotCount = 0 + var npcSlot = IntArray(65536) + var updatedNpcSlotCount: Int = 0 + var updatedNpcSlot: IntArray = IntArray(250) + + var cycle = 0 + + fun decode( + buffer: ByteBuf, + large: Boolean, + localPlayerCoord: CoordGrid, + ) { + deletedNpcCount = 0 + updatedNpcSlotCount = 0 + try { + buffer.toBitBuf().use { bitBuffer -> + processHighResolution(bitBuffer) + processLowResolution(large, bitBuffer, localPlayerCoord) + } + processExtendedInfo(buffer.toJagByteBuf()) + for (i in 0..= indexBitCount + 12) { + val index = buffer.gBits(indexBitCount) + if (capacity - 1 != index) { + var isNew = false + if (cachedNpcs[index] == null) { + cachedNpcs[index] = Npc(index, -1, CoordGrid.INVALID) + isNew = true + } + val npc = checkNotNull(cachedNpcs[index]) + npcSlot[npcSlotCount++] = index + npc.lastUpdateCycle = cycle + + npc.id = buffer.gBits(14) + val deltaX = decodeDelta(large, buffer) + val deltaZ = decodeDelta(large, buffer) + val jump = buffer.gBits(1) + val hasSpawnCycle = buffer.gBits(1) == 1 + if (hasSpawnCycle) { + npc.spawnCycle = buffer.gBits(32) + } + val angle = NPC_TURN_ANGLES[buffer.gBits(3)] + val extendedInfo = buffer.gBits(1) + if (extendedInfo == 1) { + updatedNpcSlot[updatedNpcSlotCount++] = index + } + if (isNew) { + npc.turnAngle = angle + npc.angle = angle + } + // reset bas + if (npc.turnSpeed == 0) { + npc.angle = 0 + } + npc.addRouteWaypoint( + localPlayerCoord, + deltaX, + deltaZ, + jump == 1, + ) + continue + } + } + return + } + } + + private fun decodeDelta( + large: Boolean, + buffer: BitBuf, + ): Int = + if (large) { + var delta = buffer.gBits(8) + if (delta > 127) { + delta -= 256 + } + delta + } else { + var delta = buffer.gBits(6) + if (delta > 31) { + delta -= 64 + } + delta + } + + class Npc( + val index: Int, + var id: Int, + var coord: CoordGrid, + ) { + var lastUpdateCycle: Int = 0 + var moveSpeed: MoveSpeed = MoveSpeed.STATIONARY + var turnAngle = 0 + var angle = 0 + var spawnCycle = 0 + var turnSpeed = 32 + var jump: Boolean = false + var overheadChat: String? = null + + fun addRouteWaypoint( + localPlayerCoord: CoordGrid, + relativeX: Int, + relativeZ: Int, + jump: Boolean, + ) { + coord = + CoordGrid( + localPlayerCoord.level, + localPlayerCoord.x + relativeX, + localPlayerCoord.z + relativeZ, + ) + moveSpeed = MoveSpeed.STATIONARY + this.jump = jump + } + + fun addRouteWaypointAdjacent( + opcode: Int, + speed: MoveSpeed, + ) { + var x: Int = coord.x + var z: Int = coord.z + if (opcode == 0) { + --x + ++z + } + + if (opcode == 1) { + ++z + } + + if (opcode == 2) { + ++x + ++z + } + + if (opcode == 3) { + --x + } + + if (opcode == 4) { + ++x + } + + if (opcode == 5) { + --x + --z + } + + if (opcode == 6) { + --z + } + + if (opcode == 7) { + ++x + --z + } + + coord = CoordGrid(coord.level, x, z) + moveSpeed = speed + } + + override fun toString(): String = + "Npc(" + + "index=$index, " + + "id=$id, " + + "coord=$coord, " + + "lastUpdateCycle=$lastUpdateCycle, " + + "moveSpeed=$moveSpeed, " + + "turnAngle=$turnAngle, " + + "angle=$angle, " + + "spawnCycle=$spawnCycle, " + + "turnSpeed=$turnSpeed, " + + "jump=$jump" + + ")" + } + + enum class MoveSpeed( + @Suppress("unused") val id: Int, + ) { + STATIONARY(-1), + CRAWL(0), + WALK(1), + RUN(2), + } + + private companion object { + private val NPC_TURN_ANGLES = intArrayOf(768, 1024, 1280, 512, 1536, 256, 0, 1792) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoTest.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoTest.kt new file mode 100644 index 000000000..2f48c91e7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/NpcInfoTest.kt @@ -0,0 +1,299 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.ByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatar +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFactory +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfo +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoLargeV5 +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoSmallV5 +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.random.Random +import kotlin.test.assertEquals + +class NpcInfoTest { + private lateinit var infoProtocols: InfoProtocols + private lateinit var client: NpcInfoClient + private val random: Random = Random(0) + private lateinit var serverNpcs: List + private lateinit var localNpcInfo: NpcInfo + private var localPlayerCoord = CoordGrid(0, 3207, 3207) + private lateinit var factory: NpcAvatarFactory + private lateinit var infos: Infos + + @BeforeEach + fun initialize() { + this.client = NpcInfoClient() + val storage = + ZoneIndexStorage( + ZoneIndexStorage.NPC_CAPACITY, + ) + this.factory = generateNpcAvatarFactory(storage) + this.infoProtocols = generateInfoProtocols(this.factory, storage) + this.infos = infoProtocols.alloc(500, OldSchoolClientType.DESKTOP) + this.localNpcInfo = infos.npcInfo + } + + private fun tick() { + infos.updateRootCoord( + localPlayerCoord.level, + localPlayerCoord.x, + localPlayerCoord.z, + ) + infos.updateRootBuildAreaCenteredOnPlayer( + localPlayerCoord.x, + localPlayerCoord.z, + ) + infoProtocols.npcInfoProtocol.update() + } + + private fun backingBuffer(): ByteBuf { + val packet = this.localNpcInfo.internalPacketResult(NpcInfo.ROOT_WORLD).getOrNull()!! + packet.markConsumed() + return when (packet) { + is NpcInfoSmallV5 -> packet.content() + is NpcInfoLargeV5 -> packet.content() + else -> throw IllegalStateException("Unknown npc info packet!") + } + } + + @Test + fun `adding npcs to high resolution`() { + this.serverNpcs = createPhantomNpcs(factory) + tick() + val buffer = backingBuffer() + client.decode(buffer, false, localPlayerCoord) + for (index in client.cachedNpcs.indices) { + val clientNpc = client.cachedNpcs[index] ?: continue + val serverNpc = this.serverNpcs[index] + assertEquals(serverNpc.coordGrid, clientNpc.coord) + assertEquals(serverNpc.index, clientNpc.index) + assertEquals(serverNpc.id, clientNpc.id) + } + } + + @Test + fun `removing npcs from high resolution`() { + this.serverNpcs = createPhantomNpcs(factory) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + + this.localPlayerCoord = CoordGrid(0, 2000, 2000) + infos.updateRootCoord( + localPlayerCoord.level, + localPlayerCoord.x, + localPlayerCoord.z, + ) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(0, client.npcSlotCount) + } + + @Test + fun `single npc walking`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + val clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.walk(0, 1) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + } + + @Test + fun `single npc crawling`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + val clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.crawl(0, 1) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + } + + @Test + fun `single npc running`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + val clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.walk(0, 1) + npc.avatar.walk(0, 1) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + } + + @Test + fun `single npc telejumping`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + var clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.teleport( + localPlayerCoord.level, + localPlayerCoord.x + 10, + localPlayerCoord.z + 10, + true, + ) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + // Re-obtain the instance as teleporting is equal to removal + adding + clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + } + + @Test + fun `single npc teleporting`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + var clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.teleport( + localPlayerCoord.level, + localPlayerCoord.x + 10, + localPlayerCoord.z + 10, + false, + ) + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + // Re-obtain the instance as teleporting is equal to removal + adding + clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + } + + @Test + fun `single npc overhead chat`() { + serverNpcs = createSingleNpc(factory) + // Skip everyone but the first entry + val npc = serverNpcs.first() + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(1, client.npcSlotCount) + val clientNpc = checkNotNull(client.cachedNpcs[client.npcSlot[0]]) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + + npc.avatar.extendedInfo.setSay("Hello world") + tick() + client.decode(backingBuffer(), false, localPlayerCoord) + assertEquals(npc.id, clientNpc.id) + assertEquals(npc.index, clientNpc.index) + assertEquals(npc.coordGrid, clientNpc.coord) + assertEquals("Hello world", clientNpc.overheadChat) + } + + private fun createPhantomNpcs(factory: NpcAvatarFactory): List { + val npcs = ArrayList(500) + for (index in 0..<500) { + val x = random.nextInt(3200, 3213) + val z = random.nextInt(3200, 3213) + val id = (index * x * z) and 0x3FFF + val coord = CoordGrid(0, x, z) + npcs += + Npc( + index, + id, + factory.alloc( + index, + id, + coord.level, + coord.x, + coord.z, + ), + ) + } + return npcs + } + + private fun createSingleNpc(factory: NpcAvatarFactory): List { + val npcs = ArrayList(1) + val x = random.nextInt(3200, 3213) + val z = random.nextInt(3200, 3213) + val coord = CoordGrid(0, x, z) + npcs += + Npc( + 0, + 0, + factory.alloc( + 0, + 0, + coord.level, + coord.x, + coord.z, + ), + ) + return npcs + } + + private data class Npc( + val index: Int, + val id: Int, + val avatar: NpcAvatar, + ) { + val coordGrid: CoordGrid + get() = avatar.getCoordGrid() + + override fun toString(): String = + "Npc(" + + "index=$index, " + + "id=$id, " + + "coordGrid=${avatar.getCoordGrid()}" + + ")" + } + + private companion object { + private fun NpcAvatar.getCoordGrid(): CoordGrid = CoordGrid(level(), x(), z()) + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoClient.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoClient.kt new file mode 100644 index 000000000..dab04a41b --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoClient.kt @@ -0,0 +1,567 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.buffer.bitbuffer.toBitBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +@Suppress("MemberVisibilityCanBePrivate", "CascadeIf") +class PlayerInfoClient { + var localIndex: Int = -1 + var extendedInfoCount: Int = 0 + val extendedInfoIndices: IntArray = IntArray(2048) + var highResolutionCount: Int = 0 + val highResolutionIndices: IntArray = IntArray(2048) + var lowResolutionCount: Int = 0 + val lowResolutionIndices: IntArray = IntArray(2048) + val unmodifiedFlags: ByteArray = ByteArray(2048) + val cachedPlayers: Array = arrayOfNulls(2048) + val lowResolutionPositions: IntArray = IntArray(2048) + + fun gpiInit( + localIndex: Int, + bytebuf: ByteBuf, + ) { + this.localIndex = localIndex + try { + bytebuf.toBitBuf().use { buffer -> + val localPlayer = Player(localIndex) + cachedPlayers[localIndex] = localPlayer + val coord = CoordGrid(buffer.gBits(30)) + localPlayer.coord = coord + highResolutionCount = 0 + highResolutionIndices[highResolutionCount++] = localIndex + unmodifiedFlags[localIndex] = 0 + lowResolutionCount = 0 + for (idx in 1..<2048) { + if (idx != localIndex) { + val lowResolutionPositionBitpacked = buffer.gBits(18) + val level = lowResolutionPositionBitpacked shr 16 + // Note: In osrs, the 0xFF is actually 0x255, a mixture between hexadecimal and decimal numbering. + // This is likely just an oversight, but due to only the first bit being utilized, + // this never causes problems in OSRS + val x = lowResolutionPositionBitpacked shr 8 and 0xFF + val z = lowResolutionPositionBitpacked and 0xFF + lowResolutionPositions[idx] = (x shl 14) + z + (level shl 28) + lowResolutionIndices[lowResolutionCount++] = idx + unmodifiedFlags[idx] = 0 + } + } + } + } finally { + bytebuf.release() + } + } + + fun decode(buffer: ByteBuf) { + extendedInfoCount = 0 + try { + decodeBitCodes(buffer) + } finally { + buffer.release() + } + } + + private fun decodeBitCodes(byteBuf: ByteBuf) { + byteBuf.toBitBuf().use { buffer -> + var skipped = 0 + for (i in 0.. 0) { + --skipped + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + val active = buffer.gBits(1) + if (active == 0) { + skipped = readStationary(buffer) + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + getHighResolutionPlayerPosition(buffer, idx) + } + } + } + } + if (skipped != 0) { + throw RuntimeException() + } + } + byteBuf.toBitBuf().use { buffer -> + var skipped = 0 + for (i in 0.. 0) { + --skipped + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + val active = buffer.gBits(1) + if (active == 0) { + skipped = readStationary(buffer) + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + getHighResolutionPlayerPosition(buffer, idx) + } + } + } + } + if (skipped != 0) { + throw RuntimeException() + } + } + + byteBuf.toBitBuf().use { buffer -> + var skipped = 0 + for (i in 0.. 0) { + --skipped + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + val active = buffer.gBits(1) + if (active == 0) { + skipped = readStationary(buffer) + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else if (getLowResolutionPlayerPosition(buffer, idx)) { + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } + } + } + } + if (skipped != 0) { + throw RuntimeException() + } + } + byteBuf.toBitBuf().use { buffer -> + var skipped = 0 + for (i in 0.. 0) { + --skipped + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else { + val active = buffer.gBits(1) + if (active == 0) { + skipped = readStationary(buffer) + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } else if (getLowResolutionPlayerPosition(buffer, idx)) { + unmodifiedFlags[idx] = (unmodifiedFlags[idx].toInt() or NEXT_CYCLE_INACTIVE).toByte() + } + } + } + } + if (skipped != 0) { + throw RuntimeException() + } + } + lowResolutionCount = 0 + highResolutionCount = 0 + for (i in 1..<2048) { + unmodifiedFlags[i] = (unmodifiedFlags[i].toInt() shr 1).toByte() + val cachedPlayer = cachedPlayers[i] + if (cachedPlayer != null) { + highResolutionIndices[highResolutionCount++] = i + } else { + lowResolutionIndices[lowResolutionCount++] = i + } + } + decodeExtendedInfo(byteBuf.toJagByteBuf()) + } + + private fun decodeExtendedInfo(buffer: JagByteBuf) { + for (i in 0.. 15) { + deltaX -= 32 + } + var deltaZ = coord and 31 + if (deltaZ > 15) { + deltaZ -= 32 + } + var curLevel = cachedPlayer.coord.level + var curX = cachedPlayer.coord.x + var curZ = cachedPlayer.coord.z + curX += deltaX + curZ += deltaZ + curLevel = (curLevel + deltaLevel) and 0x3 + cachedPlayer.coord = CoordGrid(curLevel, curX, curZ) + cachedPlayer.queuedMove = extendedInfo + } else { + val coord = buffer.gBits(30) + val deltaLevel = coord shr 28 + val deltaX = coord shr 14 and 16383 + val deltaZ = coord and 16383 + var curLevel = cachedPlayer.coord.level + var curX = cachedPlayer.coord.x + var curZ = cachedPlayer.coord.z + curX = (curX + deltaX) and 16383 + curZ = (curZ + deltaZ) and 16383 + curLevel = (curLevel + deltaLevel) and 0x3 + cachedPlayer.coord = CoordGrid(curLevel, curX, curZ) + cachedPlayer.queuedMove = extendedInfo + } + } + } + + private fun getLowResolutionPlayerPosition( + buffer: BitBuf, + idx: Int, + ): Boolean { + val opcode = buffer.gBits(2) + if (opcode == 0) { + if (buffer.gBits(1) != 0) { + getLowResolutionPlayerPosition(buffer, idx) + } + val x = buffer.gBits(13) + val z = buffer.gBits(13) + val extendedInfo = buffer.gBits(1) == 1 + if (extendedInfo) { + this.extendedInfoIndices[extendedInfoCount++] = idx + } + if (cachedPlayers[idx] != null) { + throw RuntimeException() + } + val player = Player(idx) + cachedPlayers[idx] = player + // cached appearance decoding + val lowResolutionPosition = lowResolutionPositions[idx] + val level = lowResolutionPosition shr 28 + val lowResX = lowResolutionPosition shr 14 and 0xFF + val lowResZ = lowResolutionPosition and 0xFF + player.coord = CoordGrid(level, (lowResX shl 13) + x, (lowResZ shl 13) + z) + player.queuedMove = false + return true + } else if (opcode == 1) { + val levelDelta = buffer.gBits(2) + val lowResPosition = lowResolutionPositions[idx] + lowResolutionPositions[idx] = + ((lowResPosition shr 28) + levelDelta and 3 shl 28) + .plus(lowResPosition and 268435455) + return false + } else if (opcode == 2) { + val bitpacked = buffer.gBits(5) + val levelDelta = bitpacked shr 3 + val movementCode = bitpacked and 7 + val lowResPosition = lowResolutionPositions[idx] + val level = (lowResPosition shr 28) + levelDelta and 3 + var x = lowResPosition shr 14 and 255 + var z = lowResPosition and 255 + if (movementCode == 0) { + --x + --z + } + + if (movementCode == 1) { + --z + } + + if (movementCode == 2) { + ++x + --z + } + + if (movementCode == 3) { + --x + } + + if (movementCode == 4) { + ++x + } + + if (movementCode == 5) { + --x + ++z + } + + if (movementCode == 6) { + ++z + } + + if (movementCode == 7) { + ++x + ++z + } + lowResolutionPositions[idx] = (x shl 14) + z + (level shl 28) + return false + } else { + val bitpacked = buffer.gBits(18) + val levelDelta = bitpacked shr 16 + val xDelta = bitpacked shr 8 and 255 + val zDelta = bitpacked and 255 + val lowResPosition = lowResolutionPositions[idx] + val level = (lowResPosition shr 28) + levelDelta and 3 + val x = xDelta + (lowResPosition shr 14) and 255 + val z = zDelta + lowResPosition and 255 + lowResolutionPositions[idx] = (x shl 14) + z + (level shl 28) + return false + } + } + + private fun readStationary(buffer: BitBuf): Int { + val type = buffer.gBits(2) + return when (type) { + 0 -> 0 + 1 -> buffer.gBits(5) + 2 -> buffer.gBits(8) + else -> buffer.gBits(11) + } + } + + companion object { + private const val CUR_CYCLE_INACTIVE = 0x1 + private const val NEXT_CYCLE_INACTIVE = 0x2 + + class Player( + val playerId: Int, + ) { + var queuedMove: Boolean = false + var coord: CoordGrid = CoordGrid.INVALID + var skullIcon: Int = -1 + var headIcon: Int = -1 + var npcId: Int = -1 + var readyAnim: Int = -1 + var turnAnim: Int = -1 + var walkAnim: Int = -1 + var walkAnimBack: Int = -1 + var walkAnimLeft: Int = -1 + var walkAnimRight: Int = -1 + var runAnim: Int = -1 + var name: String? = null + var combatLevel: Int = 0 + var skillLevel: Int = 0 + var hidden: Boolean = false + var nameExtras: Array = Array(3) { "" } + var textGender: Int = 0 + var gender: Int = 0 + var equipment: IntArray = IntArray(12) + var identKit: IntArray = IntArray(12) + var colours: IntArray = IntArray(5) + + override fun toString(): String = + "Player(" + + "playerId=$playerId, " + + "queuedMove=$queuedMove, " + + "coord=$coord, " + + "skullIcon=$skullIcon, " + + "headIcon=$headIcon, " + + "npcId=$npcId, " + + "readyAnim=$readyAnim, " + + "turnAnim=$turnAnim, " + + "walkAnim=$walkAnim, " + + "walkAnimBack=$walkAnimBack, " + + "walkAnimLeft=$walkAnimLeft, " + + "walkAnimRight=$walkAnimRight, " + + "runAnim=$runAnim, " + + "name=$name, " + + "combatLevel=$combatLevel, " + + "skillLevel=$skillLevel, " + + "hidden=$hidden, " + + "nameExtras=${nameExtras.contentToString()}, " + + "textGender=$textGender, " + + "gender=$gender, " + + "equipment=${equipment.contentToString()}, " + + "identKit=${identKit.contentToString()}, " + + "colours=${colours.contentToString()}" + + ")" + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt new file mode 100644 index 000000000..1b0ddb0fb --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt @@ -0,0 +1,189 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.Unpooled +import net.rsprot.compression.HuffmanCodec +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfo +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +class PlayerInfoTest { + private lateinit var protocol: PlayerInfoProtocol + private lateinit var localPlayerInfo: PlayerInfo + private lateinit var client: PlayerInfoClient + private lateinit var clientLocalPlayer: PlayerInfoClient.Companion.Player + private lateinit var infoProtocols: InfoProtocols + private lateinit var infos: Infos + + @BeforeEach + fun initialize() { + this.infoProtocols = generateInfoProtocols() + this.infos = infoProtocols.alloc(LOCAL_PLAYER_INDEX, OldSchoolClientType.DESKTOP) + protocol = infoProtocols.playerInfoProtocol + localPlayerInfo = infos.playerInfo + updateCoord(0, 3200, 3220) + localPlayerInfo.avatar.postUpdate() + client = PlayerInfoClient() + infos.updateRootBuildAreaCenteredOnPlayer(3200, 3220) + gpiInit() + } + + private fun gpiInit() { + val gpiBuffer = Unpooled.buffer(5000) + localPlayerInfo.handleAbsolutePlayerPositions(gpiBuffer) + client.gpiInit(LOCAL_PLAYER_INDEX, gpiBuffer) + clientLocalPlayer = checkNotNull(client.cachedPlayers[client.localIndex]) + } + + @Test + fun `test gpi init`() { + assertCoordEquals() + } + + private fun tick() { + protocol.update() + val packet = localPlayerInfo.internalPacketResult().getOrNull()!! + packet.consume() + val buffer = packet.content() + client.decode(buffer) + assertFalse(buffer.isReadable) + } + + @Test + fun `test single player consecutive movements`() { + // For local player, the coord we send in init should always be the real, high resolution + // As such, we must call tick() to for any future changes to take effect + tick() + + updateCoord(1, 3210, 3225) + tick() + assertCoordEquals() + + updateCoord(0, 512, 512) + tick() + assertCoordEquals() + + updateCoord(0, 513, 512) + tick() + assertCoordEquals() + + updateCoord(0, 3205, 3220) + tick() + assertCoordEquals() + } + + private fun updateCoord( + level: Int, + x: Int, + z: Int, + ) { + infos.updateRootCoord(level, x, z) + } + + @Test + fun `test multi player movements`() { + val otherPlayerIndices = (1..280) + val otherPlayers = arrayOfNulls(2048) + for (index in otherPlayerIndices) { + val otherPlayer = infoProtocols.alloc(index, OldSchoolClientType.DESKTOP) + otherPlayers[index] = otherPlayer + otherPlayer.updateRootCoord(0, 3205, 3220) + } + tick() + assertAllCoordsEqual(otherPlayers) + for (player in otherPlayers.filterNotNull()) { + player.updateRootCoord(0, 3204, 3220) + } + tick() + assertAllCoordsEqual(otherPlayers) + } + + @Test + fun `test single player appearance extended info`() { + localPlayerInfo.avatar.extendedInfo.setName("Local Player") + localPlayerInfo.avatar.extendedInfo.setCombatLevel(126) + localPlayerInfo.avatar.extendedInfo.setSkillLevel(1258) + localPlayerInfo.avatar.extendedInfo.setHidden(false) + localPlayerInfo.avatar.extendedInfo.setBodyType(1) + localPlayerInfo.avatar.extendedInfo.setPronoun(2) + localPlayerInfo.avatar.extendedInfo.setSkullIcon(-1) + localPlayerInfo.avatar.extendedInfo.setOverheadIcon(-1) + tick() + assertEquals("Local Player", clientLocalPlayer.name) + assertEquals(126, clientLocalPlayer.combatLevel) + assertEquals(1258, clientLocalPlayer.skillLevel) + assertEquals(false, clientLocalPlayer.hidden) + assertEquals(1, clientLocalPlayer.gender) + assertEquals(2, clientLocalPlayer.textGender) + assertEquals(-1, clientLocalPlayer.skullIcon) + assertEquals(-1, clientLocalPlayer.headIcon) + } + + @Test + fun `test multi player appearance extended info`() { + val otherPlayerIndices = (1..280) + val otherPlayers = arrayOfNulls(2048) + for (index in otherPlayerIndices) { + val otherPlayer = infoProtocols.alloc(index, OldSchoolClientType.DESKTOP) + otherPlayers[index] = otherPlayer + otherPlayer.updateRootCoord(0, 3205, 3220) + otherPlayer.playerInfo.avatar.extendedInfo + .setName("Player $index") + otherPlayer.playerInfo.avatar.extendedInfo + .setCombatLevel(126) + otherPlayer.playerInfo.avatar.extendedInfo + .setSkillLevel(index) + otherPlayer.playerInfo.avatar.extendedInfo + .setHidden(false) + otherPlayer.playerInfo.avatar.extendedInfo + .setBodyType(1) + otherPlayer.playerInfo.avatar.extendedInfo + .setPronoun(2) + otherPlayer.playerInfo.avatar.extendedInfo + .setSkullIcon(-1) + otherPlayer.playerInfo.avatar.extendedInfo + .setOverheadIcon(-1) + } + tick() + for (index in otherPlayerIndices) { + val clientPlayer = client.cachedPlayers[index] + assertNotNull(clientPlayer) + assertEquals("Player $index", clientPlayer.name) + assertEquals(126, clientPlayer.combatLevel) + assertEquals(index, clientPlayer.skillLevel) + assertEquals(false, clientPlayer.hidden) + assertEquals(1, clientPlayer.gender) + assertEquals(2, clientPlayer.textGender) + assertEquals(-1, clientPlayer.skullIcon) + assertEquals(-1, clientPlayer.headIcon) + } + } + + private fun assertAllCoordsEqual(otherPlayers: Array) { + for (i in otherPlayers.indices) { + val otherPlayer = otherPlayers[i] ?: continue + val clientPlayer = client.cachedPlayers[i]!! + assertEquals(otherPlayer.playerInfo.avatar.currentCoord, clientPlayer.coord) + } + } + + private fun assertCoordEquals() { + assertEquals(localPlayerInfo.avatar.currentCoord, clientLocalPlayer.coord) + } + + private companion object { + private const val LOCAL_PLAYER_INDEX: Int = 499 + + private fun createHuffmanCodec(): HuffmanCodec { + val resource = PlayerInfoTest::class.java.getResourceAsStream("huffman.dat") + checkNotNull(resource) { + "huffman.dat could not be found" + } + return HuffmanCodec.create(Unpooled.wrappedBuffer(resource.readBytes())) + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt new file mode 100644 index 000000000..3a795b541 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -0,0 +1,106 @@ +package net.rsprot.protocol.game.outgoing.info + +import io.netty.buffer.Unpooled +import io.netty.buffer.UnpooledByteBufAllocator +import net.rsprot.compression.HuffmanCodec +import net.rsprot.compression.provider.DefaultHuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.codec.npcinfo.DesktopLowResolutionChangeEncoder +import net.rsprot.protocol.game.outgoing.codec.npcinfo.extendedinfo.writer.NpcAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.codec.playerinfo.extendedinfo.writer.PlayerAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.codec.worldentity.extendedinfo.WorldEntityAvatarExtendedInfoDesktopWriter +import net.rsprot.protocol.game.outgoing.info.filter.DefaultExtendedInfoFilter +import net.rsprot.protocol.game.outgoing.info.npcinfo.DeferredNpcInfoProtocolSupplier +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcAvatarFactory +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +internal fun generateNpcAvatarFactory( + storage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), +): NpcAvatarFactory { + val allocator = UnpooledByteBufAllocator.DEFAULT + val protocolSupplier = DeferredNpcInfoProtocolSupplier() + return NpcAvatarFactory( + allocator, + DefaultExtendedInfoFilter(), + listOf(NpcAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + storage, + protocolSupplier, + ) +} + +internal fun generateInfoProtocols( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), +): InfoProtocols { + val allocator = UnpooledByteBufAllocator.DEFAULT + val protocolSupplier = DeferredNpcInfoProtocolSupplier() + val encoders = + ClientTypeMap.of( + listOf(DesktopLowResolutionChangeEncoder()), + OldSchoolClientType.COUNT, + ) { + it.clientType + } + val npcInfoProtocol = + NpcInfoProtocol( + allocator, + encoders, + npcAvatarFactory, + { _, e -> + e.printStackTrace() + }, + zoneIndexStorage = npcIndexStorage, + ) + protocolSupplier.supply(npcInfoProtocol) + + val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityInfoProtocol = + WorldEntityProtocol( + allocator, + exceptionHandler = { _, _ -> + }, + factory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ), + zoneIndexStorage = worldEntityStorage, + ) + + val playerAvatarFactory = + PlayerAvatarFactory( + allocator, + DefaultExtendedInfoFilter(), + listOf(PlayerAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) + val playerInfoProtocol = + PlayerInfoProtocol( + allocator, + DefaultProtocolWorker(), + playerAvatarFactory, + ) + return InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ) +} + +private fun createHuffmanCodec(): HuffmanCodec { + val resource = PlayerInfoTest::class.java.getResourceAsStream("huffman.dat") + checkNotNull(resource) { + "huffman.dat could not be found" + } + return HuffmanCodec.create(Unpooled.wrappedBuffer(resource.readBytes())) +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/resources/net/rsprot/protocol/game/outgoing/info/huffman.dat b/protocol/osrs-236/osrs-236-desktop/src/test/resources/net/rsprot/protocol/game/outgoing/info/huffman.dat new file mode 100644 index 000000000..98eab4bd4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-desktop/src/test/resources/net/rsprot/protocol/game/outgoing/info/huffman.dat @@ -0,0 +1,17 @@ +  + +   + + + + +  + + + + +     + + + + \ No newline at end of file diff --git a/protocol/osrs-236/osrs-236-internal/build.gradle.kts b/protocol/osrs-236/osrs-236-internal/build.gradle.kts new file mode 100644 index 000000000..0f34634bc --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + api(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + api(rootProject.libs.netty.transport) + api(rootProject.libs.commons.pool2) + implementation(rootProject.libs.inline.logger) + api(projects.buffer) + api(projects.compression) + api(projects.crypto) + api(projects.protocol) + api(projects.protocol.osrs236.osrs236Common) + implementation(rootProject.libs.fastutil) +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 Internal" + description = "The internal module for revision 236 OldSchool RuneScape networking, " + + "offering internal hidden implementations behind the library." + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/LogLevel.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/LogLevel.kt new file mode 100644 index 000000000..eaff03d40 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/LogLevel.kt @@ -0,0 +1,10 @@ +package net.rsprot.protocol.internal + +public enum class LogLevel { + OFF, + TRACE, + DEBUG, + INFO, + WARN, + ERROR, +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtFlags.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtFlags.kt new file mode 100644 index 000000000..ae1f0c417 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtFlags.kt @@ -0,0 +1,220 @@ +package net.rsprot.protocol.internal + +import com.github.michaelbull.logging.InlineLogger +import io.netty.util.internal.SystemPropertyUtil + +/** + * An internal object that provides easy access to various error-checking flags. + * The purpose of this object is to avoid scattering these checks throughout + * the codebase, making it difficult for users to find any. + * Additionally, requires duplication of code to re-create all this. + */ +public object RSProtFlags { + private val logger: InlineLogger = InlineLogger() + private const val PREFIX = "net.rsprot.protocol.internal." + + /** + * Whether the server is in 'development' mode. + * Development mode is effectively a mode where all + * checks are performed to ensure all inputs are validated. + * Users are expected to turn development mode off when + * putting the server into production, as these checks + * end up taking a considerable amount of time. + */ + @JvmStatic + private val development: Boolean = + getBoolean( + "development", + true, + ) + + /** + * Whether to check that obj ids in inventory packets are all positive. + */ + @JvmStatic + public val inventoryObjCheck: Boolean = + getBoolean( + "inventoryObjCheck", + development, + ) + + /** + * Whether to validate extended info block inputs. + */ + @JvmStatic + public val extendedInfoInputVerification: Boolean = + getBoolean( + "extendedInfoInputVerification", + development, + ) + + @JvmStatic + public val clientscriptVerification: Boolean = + getBoolean( + "clientscriptVerification", + development, + ) + + private val networkLoggingString: String = + getString( + "networkLogging", + "off", + ) + + private val js5LoggingString: String = + getString( + "js5Logging", + "off", + ) + + @JvmStatic + public val byteBufRecyclerCycleThreshold: Int = + getInt( + "recyclerCycleThreshold", + 50, + ) + + @JvmStatic + public val npcPlayerAvatarTracking: Boolean = + getBoolean( + "npcPlayerAvatarTracking", + true, + ) + + @JvmStatic + public val filterMissingPacketsInClient: Boolean = + getBoolean( + "filterMissingPacketsInClient", + true, + ) + + @JvmStatic + public val spotanimListCapacity: Int = + getInt( + "spotanimListCapacity", + 256, + ) + + @JvmStatic + public val captureChat: Boolean = + getBoolean( + "captureChat", + false, + ) + + @JvmStatic + public val captureSay: Boolean = + getBoolean( + "captureSay", + false, + ) + + @JvmStatic + public val singleVarShortPacketMaxAcceptedLength: Int = + getInt("singleVarShortPacketMaxAcceptedLength", 1_600) + + @JvmStatic + public val networkLogging: LogLevel = + when (networkLoggingString) { + "off" -> LogLevel.OFF + "trace" -> LogLevel.TRACE + "debug" -> LogLevel.DEBUG + "info" -> LogLevel.INFO + "warn" -> LogLevel.WARN + "error" -> LogLevel.ERROR + else -> { + logger.warn { + "Unknown network logging option: $networkLoggingString, " + + "expected values: [off, trace, debug, info, warn, error]" + } + LogLevel.OFF + } + } + + @JvmStatic + public val js5Logging: LogLevel = + when (js5LoggingString) { + "off" -> LogLevel.OFF + "trace" -> LogLevel.TRACE + "debug" -> LogLevel.DEBUG + "info" -> LogLevel.INFO + "warn" -> LogLevel.WARN + "error" -> LogLevel.ERROR + else -> { + logger.warn { + "Unknown js5 logging option: $networkLoggingString, " + + "expected values: [off, trace, debug, info, warn, error]" + } + LogLevel.OFF + } + } + + init { + log("development", development) + log("inventoryObjCheck", inventoryObjCheck) + log("extendedInfoInputVerification", extendedInfoInputVerification) + log("clientscriptVerification", clientscriptVerification) + log("networkLogging", networkLoggingString) + log("js5Logging", js5LoggingString) + log("npcPlayerAvatarTracking", npcPlayerAvatarTracking) + log("filterMissingPacketsInClient", filterMissingPacketsInClient) + log("spotanimListCapacity", spotanimListCapacity) + log("captureChat", captureChat) + log("captureSay", captureSay) + log("singleVarShortPacketMaxAcceptedLength", singleVarShortPacketMaxAcceptedLength) + + if (SystemPropertyUtil + .get( + PREFIX + "npcAvatarMaxId", + ) != null + ) { + logger.warn { + "Flag -D${PREFIX}npcAvatarMaxId is no longer supported!" + } + } + + require(spotanimListCapacity in 0..256) + require(singleVarShortPacketMaxAcceptedLength <= 5_000) { + "Single var-short packet max accepted length cannot exceed 5,000 bytes." + } + } + + private fun getBoolean( + propertyName: String, + defaultValue: Boolean, + ): Boolean = + SystemPropertyUtil.getBoolean( + PREFIX + propertyName, + defaultValue, + ) + + @Suppress("SameParameterValue") + private fun getString( + propertyName: String, + defaultValue: String, + ): String = + SystemPropertyUtil.get( + PREFIX + propertyName, + defaultValue, + ) + + @Suppress("SameParameterValue") + private fun getInt( + propertyName: String, + defaultValue: Int, + ): Int = + SystemPropertyUtil + .get( + PREFIX + propertyName, + defaultValue.toString(), + )?.toIntOrNull() ?: defaultValue + + private fun log( + name: String, + value: Any, + ) { + logger.debug { + "-D${PREFIX}$name: $value" + } + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtThreadSafety.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtThreadSafety.kt new file mode 100644 index 000000000..96863a72f --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/RSProtThreadSafety.kt @@ -0,0 +1,58 @@ +package net.rsprot.protocol.internal + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.protocol.threads.IllegalThreadAccessException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * The thread from which the server is allowed to communicate with RSProt. + */ +private val communicationThread: AtomicReference = AtomicReference() + +/** + * Whether to warn on a thread violation error. Default value is true. + */ +private val warnOnError: AtomicBoolean = AtomicBoolean(true) + +private val logger: InlineLogger = InlineLogger() + +/** + * Checks whether the calling thread is allowed to continue from this point + * onward. + * If the [communicationThread] is assigned, and it does not match with the + * caller thread, an [IllegalThreadAccessException] will be thrown. + * @throws IllegalThreadAccessException + */ +public fun checkCommunicationThread() { + val thread = communicationThread.get() ?: return + if (Thread.currentThread() === thread) return + val exception = + IllegalThreadAccessException( + "Invalid access from thread ${Thread.currentThread()}, only $thread allowed.", + ) + if (warnOnError.get()) { + logger.warn(exception) { + "Thread violation error" + } + } else { + throw exception + } +} + +/** + * Sets the thread which is permitted to communicate with RSProt's thread-unsafe + * properties. If set to null, all threads are allowed to communicate again. + * Note that while atomic instances are used for [communicationThread] and [warnOnError], + * they are not atomic as they get updated individually. Our goal is just to ensure that + * all threads read the latest value and don't use CPU cache (effectively just @Volatile) + * @param thread the thread permitted to communicate with RSProt's thread-unsafe functions. + * @param warn whether to warn on a thread violation error, rather than throwing an exception. + */ +public fun setCommunicationThread( + thread: Thread?, + warn: Boolean, +) { + communicationThread.set(thread) + warnOnError.set(warn) +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/client/ClientTypeMap.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/client/ClientTypeMap.kt new file mode 100644 index 000000000..e351eb724 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/client/ClientTypeMap.kt @@ -0,0 +1,74 @@ +package net.rsprot.protocol.internal.client + +import net.rsprot.protocol.client.ClientType + +public class ClientTypeMap + @PublishedApi + internal constructor( + private val array: Array, + ) { + public val size: Int + get() = array.size + + public val notNullSize: Int + get() = array.count { it != null } + + public operator fun get(clientType: ClientType): T = + requireNotNull(array[clientType.id]) { + "Client type $clientType not initialized!" + } + + public fun getOrNull(clientType: ClientType): T? = array[clientType.id] + + public fun getOrNull(clientId: Int): T? = array[clientId] + + public operator fun contains(clientType: ClientType): Boolean = array[clientType.id] != null + + public companion object { + public inline fun of( + elements: List, + clientCapacity: Int, + clientTypeSelector: (T) -> ClientType, + ): ClientTypeMap { + val array = arrayOfNulls(clientCapacity) + for (element in elements) { + val clientType = clientTypeSelector(element) + check(array[clientType.id] == null) { + "A client is registered more than once: $elements" + } + array[clientType.id] = element + } + return ClientTypeMap(array) + } + + public inline fun of( + clientCapacity: Int, + elements: List>, + ): ClientTypeMap { + val array = arrayOfNulls(clientCapacity) + for ((clientType, element) in elements) { + check(array[clientType.id] == null) { + "A client is registered more than once: $elements" + } + array[clientType.id] = element + } + return ClientTypeMap(array) + } + + public inline fun ofType( + elements: List, + clientCapacity: Int, + clientTypeSelector: (T) -> Pair, + ): ClientTypeMap { + val array = arrayOfNulls(clientCapacity) + for (pair in elements) { + val (clientType, element) = clientTypeSelector(pair) + check(array[clientType.id] == null) { + "A client is registered more than once: $elements" + } + array[clientType.id] = element + } + return ClientTypeMap(array) + } + } + } diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/OldSchoolZoneProt.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/OldSchoolZoneProt.kt new file mode 100644 index 000000000..bc004c2cd --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/OldSchoolZoneProt.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.internal.game.outgoing.codec.zone.payload + +public object OldSchoolZoneProt { + public const val LOC_ADD_CHANGE_V2: Int = 0 + public const val LOC_DEL: Int = 1 + public const val LOC_ANIM: Int = 2 + public const val LOC_MERGE: Int = 3 + public const val OBJ_ADD: Int = 4 + public const val OBJ_DEL: Int = 5 + public const val OBJ_COUNT: Int = 6 + public const val OBJ_ENABLED_OPS: Int = 7 + public const val MAP_ANIM: Int = 8 + public const val SOUND_AREA: Int = 9 + public const val OBJ_CUSTOMISE: Int = 10 + public const val OBJ_UNCUSTOMISE: Int = 11 + public const val MAP_PROJANIM_V2: Int = 12 +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/ZoneProtEncoder.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/ZoneProtEncoder.kt new file mode 100644 index 000000000..ae46fcf11 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/codec/zone/payload/ZoneProtEncoder.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.internal.game.outgoing.codec.zone.payload + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.message.ZoneProt +import net.rsprot.protocol.message.codec.MessageEncoder + +/** + * Zone prot encoder is an extension on message encoders, with the intent being + * that this encoder can be passed on-to + * [net.rsprot.protocol.game.outgoing.codec.zone.header.DesktopUpdateZonePartialEnclosedEncoder], + * as that packet combines multiple zone payloads into a single packet. + */ +public interface ZoneProtEncoder : MessageEncoder { + public fun encode( + buffer: JagByteBuf, + message: T, + ) + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: T, + ) { + encode(buffer, message) + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CachedExtendedInfo.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CachedExtendedInfo.kt new file mode 100644 index 000000000..fd7a333e5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CachedExtendedInfo.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.internal.game.outgoing.info + +import net.rsprot.protocol.internal.game.outgoing.info.encoder.ExtendedInfoEncoder + +/** + * Extended info blocks which get cached by the client, meaning if + * an avatar goes from low resolution to high resolution, and the client has a + * cached buffer of them, unless the server writes a new variant (in the case of a + * de-synchronization), the client will use the old buffer to restore that block. + * @param T the extended info block + * @param E the encoder for that extended info block + */ +public abstract class CachedExtendedInfo, E : ExtendedInfoEncoder> : ExtendedInfo() diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordFine.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordFine.kt new file mode 100644 index 000000000..101572dfd --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordFine.kt @@ -0,0 +1,76 @@ +package net.rsprot.protocol.internal.game.outgoing.info + +/** + * CoordFine represents a precise coordinate in the client. The values here are [CoordGrid]'s + * values, multiplier by 128 for x/z coordinates. + */ +@JvmInline +public value class CoordFine( + public val packed: Long, +) { + /** + * @param x the absolute fine x coordinate of the avatar. + * @param y the fine y coordinate (or height) of this avatar. + * @param z the absolute fine z coordinate of the avatar. + */ + public constructor( + x: Int, + y: Int, + z: Int, + ) : this( + (y.toLong() shl 42) + .or(x.toLong() shl 21) + .or(z.toLong()), + ) { + require(y in 0..<1_024) { + "Y coordinate must be in range of 0..<1_024: $y" + } + require(x in 0..2_097_151) { + "X coordinate must be in range of 0..<2_097_151: $x" + } + require(z in 0..2_097_151) { + "Z coordinate must be in range of 0..<2_097_151, $z" + } + } + + public fun copy( + x: Int = this.x, + y: Int = this.y, + z: Int = this.z, + ): CoordFine { + return CoordFine( + x, + y, + z, + ) + } + + public val x: Int + get() = (packed ushr 21 and 0x1FFFFF).toInt() + public val y: Int + get() = (packed ushr 42).toInt() + public val z: Int + get() = (packed and 0x1FFFFF).toInt() + + /** + * Converts the coord fine into a coord grid. + * @param level the level which to return. This is because the [y] coordinate is not + * linked to the actual level at which the coord exists. + * @return CoordGrid that aligns with this CoordFine. + */ + public fun toCoordGrid(level: Int): CoordGrid { + return CoordGrid(level, x ushr 7, z ushr 7) + } + + override fun toString(): String { + return "CoordFine(" + + "x=$x, " + + "y=$y, " + + "z=$z" + + ")" + } + + public companion object { + public val INVALID: CoordFine = CoordFine(-1) + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordGrid.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordGrid.kt new file mode 100644 index 000000000..49c4815d0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/CoordGrid.kt @@ -0,0 +1,96 @@ +package net.rsprot.protocol.internal.game.outgoing.info + +/** + * Coord grid, commonly referred to just as Coordinate or Location, + * is responsible for tracking absolute positions of avatars in the game. + * @param packed the 30-bit bitpacked integer representing the coord grid. + */ +@JvmInline +public value class CoordGrid( + public val packed: Int, +) { + /** + * @param level the height level of the avatar. + * @param x the absolute x coordinate of the avatar. + * @param z the absolute z coordinate of the avatar. + */ + @Suppress("ConvertTwoComparisonsToRangeCheck") + public constructor( + level: Int, + x: Int, + z: Int, + ) : this( + (level shl 28) + .or(x shl 14) + .or(z), + ) { + // https://youtrack.jetbrains.com/issue/KT-62798/in-range-checks-are-not-intrinsified-in-kotlin-stdlib + // Using traditional checks to avoid generating range objects (seen by decompiling this class) + require(level >= 0 && level < 4) { + "Level must be in range of 0..<4: $level" + } + require(x >= 0 && x <= 16384) { + "X coordinate must be in range of 0..<16384: $x" + } + require(z >= 0 && z <= 16384) { + "Z coordinate must be in range of 0..<16384, $z" + } + } + + public val level: Int + get() = packed ushr 28 + public val x: Int + get() = packed ushr 14 and 0x3FFF + public val z: Int + get() = packed and 0x3FFF + + /** + * Checks whether this coord grid is within [distance] of the [other] coord grid. + * If the coord grids are on different levels, this function will always return false. + * @param other the other coord grid to check against. + * @param distance the distance to check (inclusive). A distance of 0 implies same coordinate. + * @return true if the [other] coord grid is within [distance] of this coord grid. + */ + public fun inDistance( + other: CoordGrid, + distance: Int, + ): Boolean { + if (level != other.level) { + return false + } + val deltaX = x - other.x + if (deltaX !in -distance..distance) { + return false + } + val deltaZ = z - other.z + return deltaZ in -distance..distance + } + + /** + * Checks if this coord grid is uninitialized. + * Uninitialized coord grids are determined by checking if all 32 bits of + * the [packed] property are enabled (including sign bit, which would be the opposite). + * As the main constructor of this class only takes in the components that build a coord grid, + * it is impossible to make an instance of this that matches the invalid value, + * unless directly using the single-argument constructor. + */ + @Suppress("NOTHING_TO_INLINE") + public inline fun invalid(): Boolean = this == INVALID + + public operator fun component1(): Int = level + + public operator fun component2(): Int = x + + public operator fun component3(): Int = z + + override fun toString(): String = + "CoordGrid(" + + "level=$level, " + + "x=$x, " + + "z=$z" + + ")" + + public companion object { + public val INVALID: CoordGrid = CoordGrid(-1) + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/ExtendedInfo.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/ExtendedInfo.kt new file mode 100644 index 000000000..54e7ef08f --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/ExtendedInfo.kt @@ -0,0 +1,118 @@ +package net.rsprot.protocol.internal.game.outgoing.info + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import io.netty.util.ReferenceCountUtil +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.encoder.ExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The abstract extended info class, responsible for holding some + * information about a specific avatar. + * @param T the extended info block type. + * @param E the encoder for the given extended info block [T]. + */ +public abstract class ExtendedInfo, E : ExtendedInfoEncoder> { + public abstract val encoders: ClientTypeMap + + /** + * An array of client-specific pre-computed buffers of this extended info block. + * These buffers get pre-computed during player info building process, + * and the pre-computed buffers will be natively copied over to the main buffer + * by all the observers. + * For extended info blocks which cannot be pre-computed, the building happens on-demand. + */ + private val buffers: Array = arrayOfNulls(OldSchoolClientType.COUNT) + + /** + * Sets the client-specific [buffer] at index [clientTypeId]. + * @param clientTypeId the id of the client, additionally used as the key to the [buffers] array. + * @param buffer the pre-computed buffer for this extended info block. + */ + public fun setBuffer( + clientTypeId: Int, + buffer: ByteBuf, + ) { + buffers[clientTypeId] = buffer + } + + /** + * Gets the latest pre-computed buffer for the given [oldSchoolClientType]. + * @param oldSchoolClientType the client for which to obtain the buffer. + * @return the pre-computed buffer, or null if it does not exist. + */ + public fun getBuffer(oldSchoolClientType: OldSchoolClientType): ByteBuf? = buffers[oldSchoolClientType.id] + + /** + * Gets the encoder for a given [oldSchoolClientType]. + * @param oldSchoolClientType the client type for which to obtain the encoder. + * @return the client-specific encoder of this extended info block, or null + * if one has not been registered. + */ + public fun getEncoder(oldSchoolClientType: OldSchoolClientType): E? = encoders.getOrNull(oldSchoolClientType) + + /** + * Releases all the client-specific buffers of this extended info block, + * which will either be garbage-collected or returned into the bytebuf pool. + */ + internal fun releaseBuffers() { + try { + for (i in 0.. 0) { + ReferenceCountUtil.safeRelease(buffer, refCnt) + } + buffers[i] = null + } + } catch (e: Exception) { + logger.error(e) { + "Unable to release old buffers" + } + } + } + + /** + * Checks whether a buffer has been precomputed on the specified client type. + * @param oldSchoolClientType the client for which to check a precomputed buffer. + * @return whether the buffer has been precomputed for the specified client. + */ + public fun isPrecomputed(oldSchoolClientType: OldSchoolClientType): Boolean = + buffers[oldSchoolClientType.id] != null + + /** + * Clears this extended info block, making it ready for use by another avatar. + */ + public abstract fun clear() + + private companion object { + private val logger = InlineLogger() + } +} + +/** + * A function to pre-compute this extended info block. + * Extended info blocks which do not support pre-computing (meaning they are observer-dependent) + * will build the buffer on-demand per observer. + */ +public fun , E : PrecomputedExtendedInfoEncoder> T.precompute( + allocator: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, +) { + // Release any old buffers before overwriting with new ones + releaseBuffers() + for (id in 0.., E : ExtendedInfoEncoder> : + ExtendedInfo() diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/ExtendedInfoEncoder.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/ExtendedInfoEncoder.kt new file mode 100644 index 000000000..e36710db2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/ExtendedInfoEncoder.kt @@ -0,0 +1,8 @@ +package net.rsprot.protocol.internal.game.outgoing.info.encoder + +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo + +/** + * Extended info encoders are responsible for turning [T] into a byte buffer. + */ +public interface ExtendedInfoEncoder> diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/OnDemandExtendedInfoEncoder.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/OnDemandExtendedInfoEncoder.kt new file mode 100644 index 000000000..3fc184ca0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/OnDemandExtendedInfoEncoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.internal.game.outgoing.info.encoder + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo + +/** + * On-demand extended info encoders are invoked on every observer whenever information must be written. + * These differ from [PrecomputedExtendedInfoEncoder] in that they cannot be pre-computed, as the + * data in the buffer is dependent on the observer. + */ +public interface OnDemandExtendedInfoEncoder> : ExtendedInfoEncoder { + public fun encode( + buffer: JagByteBuf, + localPlayerIndex: Int, + updatedAvatarIndex: Int, + extendedInfo: T, + ) +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/PrecomputedExtendedInfoEncoder.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/PrecomputedExtendedInfoEncoder.kt new file mode 100644 index 000000000..9cb1cb9d6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/encoder/PrecomputedExtendedInfoEncoder.kt @@ -0,0 +1,19 @@ +package net.rsprot.protocol.internal.game.outgoing.info.encoder + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo + +/** + * Pre-computed extended info encoders encode the data for all necessary extended info blocks + * early on in the process. This allows us to do a simple native buffer copy to transfer the data over, + * and avoids us having to re-calculate all the little properties that end up being encoded. + */ +public interface PrecomputedExtendedInfoEncoder> : ExtendedInfoEncoder { + public fun precompute( + alloc: ByteBufAllocator, + huffmanCodecProvider: HuffmanCodecProvider, + extendedInfo: T, + ): JagByteBuf +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/NpcAvatarDetails.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/NpcAvatarDetails.kt new file mode 100644 index 000000000..f3c94ce6d --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/NpcAvatarDetails.kt @@ -0,0 +1,145 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo + +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +/** + * An internal class holding the state of NPC's avatar, containing everything + * that is sent during the movement from low to high resolution. + * @property index the index of the npc in the world + * @property id the id of the npc in the world + * @property currentCoord the current coordinate of the npc + * @property stepCount the number of steps the npc has taken this cycle + * @property firstStep the direction of the first step that the npc has taken this cycle, or -1 + * @property secondStep the direction of the second step that the npc has taken this cycle, or -2 + * @property movementType the bitpacked flag of all the movement typed the npc has utilized this cycle + * @property spawnCycle the game cycle on which the npc was originally spawned into the world + * @property direction the direction that the npc is facing when it is first added to high resolution view + * @property inaccessible whether the npc is inaccessible to all players, meaning it will not be + * added to high resolution for anyone, even though it is still within the zone. This is intended + * to be used with static npcs that respawn. After death, inaccessible should be set to true, and + * when the npc respawns, it should be set back to false. This allows us to not re-allocate avatars + * which furthermore requires cleanup and micromanaging. + * @property priorityBitcode the bitcode indicating whether the NPC belongs in normal or low priority + * group. + * @property specific if true, the NPC will only render to players that have explicitly marked this + * NPC's index as specific-visible, anyone else will be unable to see it. If it's false, anyone can + * see the NPC regardless. + */ +public class NpcAvatarDetails internal constructor( + public var index: Int, + public var id: Int, + public var currentCoord: CoordGrid = CoordGrid.INVALID, + public var stepCount: Int = 0, + public var firstStep: Int = -1, + public var secondStep: Int = -1, + public var movementType: Int = 0, + public var spawnCycle: Int = 0, + public var direction: Int = 0, + public var inaccessible: Boolean = false, + public var priorityBitcode: Int = 0, + public var specific: Boolean = false, + public var allocateCycle: Int, + public var renderDistance: Int, +) { + public constructor( + index: Int, + id: Int, + level: Int, + x: Int, + z: Int, + spawnCycle: Int = 0, + direction: Int = 0, + priorityBitcode: Int = 0, + specific: Boolean = false, + allocateCycle: Int, + renderDistance: Int, + ) : this( + index, + id, + CoordGrid(level, x, z), + spawnCycle = spawnCycle, + direction = direction, + priorityBitcode = priorityBitcode, + specific = specific, + allocateCycle = allocateCycle, + renderDistance = renderDistance, + ) + + /** + * Whether the npc is tele jumping, meaning it will jump over to the destination + * coord, even if it is just one tile away. + */ + public fun isJumping(): Boolean = movementType and TELEJUMP != 0 + + /** + * Whether the npc is teleporting, but not explicitly jumping. + */ + public fun isTeleWithoutJump(): Boolean = movementType and TELE != 0 + + /** + * Whether the npc is teleporting. This means the npc will render as jumping + * if the destination is > 2 tiles away, and normal walk/run/in-between if the + * distance is 2 tiles or fewer. + */ + public fun isTeleporting(): Boolean = movementType and (TELE or TELEJUMP) != 0 + + /** + * Updates the current direction of the npc, allowing the server to sync up + * the current faced coordinate of npcs during movement, face angle and such. + */ + public fun updateDirection(direction: Int) { + this.direction = direction + } + + override fun toString(): String = + "NpcAvatarDetails(" + + "index=$index, " + + "id=$id, " + + "currentCoord=$currentCoord, " + + "stepCount=$stepCount, " + + "firstStep=$firstStep, " + + "secondStep=$secondStep, " + + "movementType=$movementType, " + + "spawnCycle=$spawnCycle, " + + "direction=$direction, " + + "inaccessible=$inaccessible, " + + "priorityBitcode=$priorityBitcode, " + + "specific=$specific, " + + "allocateCycle=$allocateCycle, " + + "renderDistance=$renderDistance" + + ")" + + public companion object { + /** + * The constant flag movement type indicating the npc did crawl. + */ + public const val CRAWL: Int = 0x1 + + /** + * The constant flag movement type indicating the npc did walk. + */ + public const val WALK: Int = 0x2 + + /** + * The constant flag movement type indicating the npc did run. + * Run state is additionally reached if two walks, two crawls or a mix of + * a crawl and walk was used in one cycle. More than two walks/crawls will + * however turn into a telejump. + * This flag has a higher priority than crawl or walk, but is surpassed by both teleports. + */ + public const val RUN: Int = 0x4 + + /** + * The constant flag indicating the npc is teleporting without a jump. + * The jump condition is automatically included if the npc moves more than 2 tiles. + * This flag has the highest priority out of all above, only surpassed by telejump. + */ + public const val TELE: Int = 0x8 + + /** + * The constant flag indicating the npc is jumping regardless of distance. + * This flag has the highest priority out of all above. + */ + public const val TELEJUMP: Int = 0x10 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcExtendedInfoEncoders.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcExtendedInfoEncoders.kt new file mode 100644 index 000000000..ea84c5962 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcExtendedInfoEncoders.kt @@ -0,0 +1,45 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BaseAnimationSet +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BodyCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.CombatLevelChange +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadIconCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NameChange +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NpcTinting +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.Transformation +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.ExactMove +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps + +/** + * A data class to bring all the extended info encoders for a given client together. + * @param oldSchoolClientType the client for which these encoders are created. + */ +public data class NpcExtendedInfoEncoders( + public val oldSchoolClientType: OldSchoolClientType, + public val spotAnim: PrecomputedExtendedInfoEncoder, + public val say: PrecomputedExtendedInfoEncoder, + public val visibleOps: PrecomputedExtendedInfoEncoder, + public val exactMove: PrecomputedExtendedInfoEncoder, + public val sequence: PrecomputedExtendedInfoEncoder, + public val tinting: PrecomputedExtendedInfoEncoder, + public val headIconCustomisation: PrecomputedExtendedInfoEncoder, + public val nameChange: PrecomputedExtendedInfoEncoder, + public val headCustomisation: PrecomputedExtendedInfoEncoder, + public val bodyCustomisation: PrecomputedExtendedInfoEncoder, + public val transformation: PrecomputedExtendedInfoEncoder, + public val combatLevelChange: PrecomputedExtendedInfoEncoder, + public val hit: OnDemandExtendedInfoEncoder, + public val faceAngle: PrecomputedExtendedInfoEncoder, + public val facePathingEntity: PrecomputedExtendedInfoEncoder, + public val baseAnimationSet: PrecomputedExtendedInfoEncoder, +) diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcResolutionChangeEncoder.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcResolutionChangeEncoder.kt new file mode 100644 index 000000000..2c5372e31 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/encoder/NpcResolutionChangeEncoder.kt @@ -0,0 +1,19 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder + +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.NpcAvatarDetails + +public interface NpcResolutionChangeEncoder { + public val clientType: OldSchoolClientType + + public fun encode( + bitBuffer: BitBuf, + details: NpcAvatarDetails, + extendedInfo: Boolean, + localPlayerCoordGrid: CoordGrid, + largeDistance: Boolean, + cycleCount: Int, + ) +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BaseAnimationSet.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BaseAnimationSet.kt new file mode 100644 index 000000000..ec9e76fc9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BaseAnimationSet.kt @@ -0,0 +1,65 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class BaseAnimationSet( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var overrides: Int = DEFAULT_OVERRIDES_FLAG + public var turnLeftAnim: UShort = 0xFFFFu + public var turnRightAnim: UShort = 0xFFFFu + public var walkAnim: UShort = 0xFFFFu + public var walkAnimBack: UShort = 0xFFFFu + public var walkAnimLeft: UShort = 0xFFFFu + public var walkAnimRight: UShort = 0xFFFFu + public var runAnim: UShort = 0xFFFFu + public var runAnimBack: UShort = 0xFFFFu + public var runAnimLeft: UShort = 0xFFFFu + public var runAnimRight: UShort = 0xFFFFu + public var crawlAnim: UShort = 0xFFFFu + public var crawlAnimBack: UShort = 0xFFFFu + public var crawlAnimLeft: UShort = 0xFFFFu + public var crawlAnimRight: UShort = 0xFFFFu + public var readyAnim: UShort = 0xFFFFu + + override fun clear() { + releaseBuffers() + overrides = DEFAULT_OVERRIDES_FLAG + turnLeftAnim = 0xFFFFu + turnRightAnim = 0xFFFFu + walkAnim = 0xFFFFu + walkAnimBack = 0xFFFFu + walkAnimLeft = 0xFFFFu + walkAnimRight = 0xFFFFu + runAnim = 0xFFFFu + runAnimBack = 0xFFFFu + runAnimLeft = 0xFFFFu + runAnimRight = 0xFFFFu + crawlAnim = 0xFFFFu + crawlAnimBack = 0xFFFFu + crawlAnimLeft = 0xFFFFu + crawlAnimRight = 0xFFFFu + readyAnim = 0xFFFFu + } + + public companion object { + public const val DEFAULT_OVERRIDES_FLAG: Int = 0 + public const val TURN_LEFT_ANIM_FLAG: Int = 0x1 + public const val TURN_RIGHT_ANIM_FLAG: Int = 0x2 + public const val WALK_ANIM_FLAG: Int = 0x4 + public const val WALK_ANIM_BACK_FLAG: Int = 0x8 + public const val WALK_ANIM_LEFT_FLAG: Int = 0x10 + public const val WALK_ANIM_RIGHT_FLAG: Int = 0x20 + public const val RUN_ANIM_FLAG: Int = 0x40 + public const val RUN_ANIM_BACK_FLAG: Int = 0x80 + public const val RUN_ANIM_LEFT_FLAG: Int = 0x100 + public const val RUN_ANIM_RIGHT_FLAG: Int = 0x200 + public const val CRAWL_ANIM_FLAG: Int = 0x400 + public const val CRAWL_ANIM_BACK_FLAG: Int = 0x800 + public const val CRAWL_ANIM_LEFT_FLAG: Int = 0x1000 + public const val CRAWL_ANIM_RIGHT_FLAG: Int = 0x2000 + public const val READY_ANIM_FLAG: Int = 0x4000 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BodyCustomisation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BodyCustomisation.kt new file mode 100644 index 000000000..c35ef799c --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/BodyCustomisation.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class BodyCustomisation( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var customisation: TypeCustomisation? = null + + override fun clear() { + releaseBuffers() + this.customisation = null + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/CombatLevelChange.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/CombatLevelChange.kt new file mode 100644 index 000000000..d81421b77 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/CombatLevelChange.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class CombatLevelChange( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var level: Int = + DEFAULT_LEVEL_OVERRIDE + + override fun clear() { + releaseBuffers() + this.level = + DEFAULT_LEVEL_OVERRIDE + } + + public companion object { + public const val DEFAULT_LEVEL_OVERRIDE: Int = -1 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadCustomisation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadCustomisation.kt new file mode 100644 index 000000000..7ee9c4830 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadCustomisation.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class HeadCustomisation( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var customisation: TypeCustomisation? = null + + override fun clear() { + releaseBuffers() + this.customisation = null + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadIconCustomisation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadIconCustomisation.kt new file mode 100644 index 000000000..6b62c2750 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/HeadIconCustomisation.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class HeadIconCustomisation( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var flag: Int = DEFAULT_FLAG + public val headIconGroups: IntArray = + IntArray(8) { + -1 + } + public val headIconIndices: ShortArray = + ShortArray(8) { + -1 + } + + override fun clear() { + releaseBuffers() + flag = DEFAULT_FLAG + headIconGroups.fill(-1) + headIconIndices.fill(-1) + } + + public companion object { + public const val DEFAULT_FLAG: Int = 0 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NameChange.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NameChange.kt new file mode 100644 index 000000000..775f4ddb6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NameChange.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class NameChange( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var name: String? = null + + override fun clear() { + releaseBuffers() + this.name = null + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NpcTinting.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NpcTinting.kt new file mode 100644 index 000000000..c4c1f3727 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/NpcTinting.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Tinting + +/** + * The tinting extended info block. + * @param encoders the array of client-specific encoders for tinting. + */ +public class NpcTinting( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public val global: Tinting = + Tinting() + + override fun clear() { + releaseBuffers() + global.reset() + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/Transformation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/Transformation.kt new file mode 100644 index 000000000..bd010e54b --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/Transformation.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class Transformation( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var id: UShort = 0xFFFFu + + override fun clear() { + releaseBuffers() + this.id = 0xFFFFu + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/TypeCustomisation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/TypeCustomisation.kt new file mode 100644 index 000000000..5b789be52 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/npcinfo/extendedinfo/TypeCustomisation.kt @@ -0,0 +1,8 @@ +package net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo + +public class TypeCustomisation( + public val models: List, + public val recolours: List, + public val retexture: List, + public val mirror: Boolean?, +) diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/encoder/PlayerExtendedInfoEncoders.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/encoder/PlayerExtendedInfoEncoders.kt new file mode 100644 index 000000000..ac98dd02f --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/encoder/PlayerExtendedInfoEncoders.kt @@ -0,0 +1,37 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.encoder + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Appearance +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Chat +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.MoveSpeed +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.PlayerTintingList +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.TemporaryMoveSpeed +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.ExactMove +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList + +/** + * A data class to bring all the extended info encoders for a given client together. + * @param oldSchoolClientType the client for which these encoders are created. + */ +public data class PlayerExtendedInfoEncoders( + public val oldSchoolClientType: OldSchoolClientType, + public val appearance: PrecomputedExtendedInfoEncoder, + public val chat: PrecomputedExtendedInfoEncoder, + public val exactMove: PrecomputedExtendedInfoEncoder, + public val faceAngle: PrecomputedExtendedInfoEncoder, + public val facePathingEntity: PrecomputedExtendedInfoEncoder, + public val hit: OnDemandExtendedInfoEncoder, + public val moveSpeed: PrecomputedExtendedInfoEncoder, + public val say: PrecomputedExtendedInfoEncoder, + public val sequence: PrecomputedExtendedInfoEncoder, + public val spotAnim: PrecomputedExtendedInfoEncoder, + public val temporaryMoveSpeed: PrecomputedExtendedInfoEncoder, + public val tinting: OnDemandExtendedInfoEncoder, +) diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Appearance.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Appearance.kt new file mode 100644 index 000000000..b825f5a97 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Appearance.kt @@ -0,0 +1,232 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.CachedExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The appearance extended info block. + * This is an unusually-large extended info block that is also the only extended info block + * which gets cached client-side. + * The library utilizes that caching through a counter which increments with each modification + * done to the appearance. When an avatar goes from low resolution to high resolution, + * a comparison is done against the cache, if the counters match, no extended info block is written. + * If an avatar logs out, every observer will have their counter set back to -1. + * @param encoders the array of client-specific encoders for appearance. + */ +public class Appearance( + override val encoders: ClientTypeMap>, +) : CachedExtendedInfo>() { + /** + * The name of this avatar. + */ + public var name: String = "" + + /** + * The combat level of this avatar. + */ + public var combatLevel: UByte = 0u + + /** + * The skill level of this avatar, shown on the right-click menu as "skill-number". + * This is utilized within Burthorpe's games' room. + */ + public var skillLevel: UShort = 0u + + /** + * Whether this avatar is soft-hidden, meaning client will not render the model itself + * for anyone except J-Mods. Clients such as RuneLite will ignore this property within + * any plugins. + */ + public var hidden: Boolean = false + + /** + * The type of body the avatar is using. + */ + public var bodyType: UByte = 0u + + /** + * The type of pronoun to utilize within clientscripts. + */ + public var pronoun: UByte = MAX_UNSIGNED_BYTE + + /** + * The skull icon that appears over-head, mostly in PvP scenarios. + */ + public var skullIcon: UByte = MAX_UNSIGNED_BYTE + + /** + * The overhead icon that's utilized with prayers. + */ + public var overheadIcon: UByte = MAX_UNSIGNED_BYTE + + /** + * The id of the npc to which this avatar has transformed. + */ + public var transformedNpcId: UShort = MAX_UNSIGNED_SHORT + + /** + * An array of ident kit ids, indexed by the respective wearpos. + */ + public val identKit: ShortArray = ShortArray(7) { -1 } + + /** + * The worn obj ids, indexed by the respective wearpos. + */ + public val wornObjs: ShortArray = ShortArray(SLOT_COUNT) { -1 } + + /** + * The secondary and tertiary wearpos that the primary wearpos + * ends up hiding. The secondary and tertiary values are bitpacked + * into a single byte. We track this separately, so we can always + * get the full idea of what the avatar is built up out of. + */ + public val hiddenWearPos: ByteArray = ByteArray(SLOT_COUNT) { -1 } + + /** + * The colours the avatar's model is made up of. + */ + public var colours: ByteArray = ByteArray(COLOUR_COUNT) + + /** + * The animation used when the avatar is standing still. + */ + public var readyAnim: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is turning on-spot without movement. + */ + public var turnAnim: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is walking forward. + */ + public var walkAnim: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is walking backwards. + */ + public var walkAnimBack: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is walking to the left. + */ + public var walkAnimLeft: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is walking to the right. + */ + public var walkAnimRight: UShort = MAX_UNSIGNED_SHORT + + /** + * The animation used when the avatar is running. + */ + public var runAnim: UShort = MAX_UNSIGNED_SHORT + + /** + * Whether to force a model refresh client-side, removing the cached model of the player + * even if the worn objects + base colour + gender have not changed. + * This is important to flag when setting or removing an obj type customization. + */ + public var forceModelRefresh: Boolean = false + + /** + * The customisations applied to worn objs, indexed by the respective obj's primary wearpos. + */ + public val objTypeCustomisation: Array = arrayOfNulls(12) + + /** + * The string to render before an avatar's name in the right-click menu, + * used within the Burthorpe games' room. + */ + public var beforeName: String = "" + + /** + * The string to render after an avatar's name in the right-click menu, + * used within the Burthorpe games' room. + */ + public var afterName: String = "" + + /** + * The string to render after an avatar's combat level in the right-click menu, + * used within the Burthorpe games' room. + */ + public var afterCombatLevel: String = "" + + override fun clear() { + releaseBuffers() + name = "" + combatLevel = 0u + skillLevel = 0u + hidden = false + bodyType = 0u + pronoun = MAX_UNSIGNED_BYTE + skullIcon = MAX_UNSIGNED_BYTE + overheadIcon = MAX_UNSIGNED_BYTE + transformedNpcId = MAX_UNSIGNED_SHORT + identKit.fill(-1) + wornObjs.fill(-1) + hiddenWearPos.fill(-1) + colours.fill(0) + forceModelRefresh = false + objTypeCustomisation.fill(null) + readyAnim = MAX_UNSIGNED_SHORT + turnAnim = MAX_UNSIGNED_SHORT + walkAnim = MAX_UNSIGNED_SHORT + walkAnimBack = MAX_UNSIGNED_SHORT + walkAnimLeft = MAX_UNSIGNED_SHORT + walkAnimRight = MAX_UNSIGNED_SHORT + runAnim = MAX_UNSIGNED_SHORT + } + + public companion object { + /** + * The number of wearpos that the client will track. + */ + private const val SLOT_COUNT: Int = 12 + + /** + * The number of colours that the client tracks. + */ + private const val COLOUR_COUNT: Int = 5 + + /** + * A constant for max unsigned byte, frequently used as the "default, not initialized" value. + */ + private const val MAX_UNSIGNED_BYTE: UByte = 0xFFu + + /** + * A constant for max unsigned short, frequently used as the "default, not initialized" value. + */ + private const val MAX_UNSIGNED_SHORT: UShort = 0xFFFFu + + private const val HAIR_IDENTKIT: Int = 0 + private const val BEARD_IDENTKIT: Int = 1 + private const val BODY_IDENTKIT: Int = 2 + private const val ARMS_IDENTKIT: Int = 3 + private const val GLOVES_IDENTKIT: Int = 4 + private const val LEGS_IDENTKIT: Int = 5 + private const val BOOTS_IDENTKIT: Int = 6 + + /** + * An array of wearpos -> ident kit slot, indexed by wearpos. + */ + public val identKitSlotList: List = + listOf( + -1, + -1, + -1, + -1, + BODY_IDENTKIT, + -1, + ARMS_IDENTKIT, + LEGS_IDENTKIT, + HAIR_IDENTKIT, + GLOVES_IDENTKIT, + BOOTS_IDENTKIT, + BEARD_IDENTKIT, + -1, + -1, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Chat.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Chat.kt new file mode 100644 index 000000000..9db085f87 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/Chat.kt @@ -0,0 +1,53 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The chat extended info block, responsible for any public messages. + * @param encoders the array of client-specific encoders for chat. + */ +public class Chat( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The colour to apply to this chat message. + */ + public var colour: UByte = 0u + + /** + * The effect to apply to this chat message. + */ + public var effects: UByte = 0u + + /** + * The mod icon to render next to the name of the avatar who said this message. + */ + public var modicon: UByte = 0u + + /** + * Whether this avatar is using the built-in autotyper mechanic. + */ + public var autotyper: Boolean = false + + /** + * The text itself to render. This will be compressed using the [net.rsprot.compression.HuffmanCodec]. + */ + public var text: String? = null + + /** + * The colour pattern for specialized chat message colours, + */ + public var pattern: ByteArray? = null + + override fun clear() { + releaseBuffers() + colour = 0u + effects = 0u + modicon = 0u + autotyper = false + text = null + pattern = null + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/MoveSpeed.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/MoveSpeed.kt new file mode 100644 index 000000000..1dc4cea3a --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/MoveSpeed.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The movement speed extended info block. + * Unlike most extended info blocks, the [value] will last as long as the server tells it to. + * The client will also temporarily cache it for the duration that it sees an avatar in high resolution. + * Whenever an avatar moves, unless the move speed has been overwritten, this is the speed + * that it will use for the movement, barring any special mechanics. + * If an avatar goes from high resolution to low resolution, the client **will not** cache this, + * and a new status update must be written when the opposite transition occurs. + * This move speed status should typically be synchronized with the state of the "Run orb". + * @param encoders the array of client-specific encoders for move speed. + */ +public class MoveSpeed( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The current movement speed of this avatar. + */ + public var value: Int = DEFAULT_MOVESPEED + + override fun clear() { + releaseBuffers() + value = DEFAULT_MOVESPEED + } + + public companion object { + public const val DEFAULT_MOVESPEED: Int = 0 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/ObjTypeCustomisation.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/ObjTypeCustomisation.kt new file mode 100644 index 000000000..e11a5d354 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/ObjTypeCustomisation.kt @@ -0,0 +1,44 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +/** + * A class to track modifications done to a specific worn obj. + * @param recolIndices the bitpacked indices of the source colour to overwrite. + * @param recol1 the colour value to overwrite the source colour at the first index with. + * @param recol2 the colour value to overwrite the source colour at the second index with. + * @param retexIndices the bitpacked indices of the source texture to overwrite. + * @param retex1 the texture id to overwrite the source texture at the first index with. + * @param retex2 the texture id to overwrite the source texture at the second index with. + * @param manWear the male body type wear model + * @param womanWear the female body type wear model + * @param manHead the male chathead model + * @param womanHead the female chathead model + */ +public class ObjTypeCustomisation( + public var recolIndices: UByte, + public var recol1: UShort, + public var recol2: UShort, + public var retexIndices: UByte, + public var retex1: UShort, + public var retex2: UShort, + public var manWear: UShort, + public var womanWear: UShort, + public var manHead: UShort, + public var womanHead: UShort, +) { + public constructor() : this( + recolIndices = 0xFFu, + recol1 = 0u, + recol2 = 0u, + retexIndices = 0xFFu, + retex1 = 0u, + retex2 = 0u, + manWear = DEFAULT_MODEL, + womanWear = DEFAULT_MODEL, + manHead = DEFAULT_MODEL, + womanHead = DEFAULT_MODEL, + ) + + public companion object { + public const val DEFAULT_MODEL: UShort = 0xFFFFU + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/PlayerTintingList.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/PlayerTintingList.kt new file mode 100644 index 000000000..6a7a25328 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/PlayerTintingList.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Tinting + +/** + * The tinting extended info block. + * This is a rather special case as tinting is one of the two observer-dependent extended info blocks, + * along with [net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit]. + * It is possible for the server to mark tinting for only a single avatar to see. + * In order to achieve this, we utilize [observerDependent] tinting, indexed by the observer's id. + * @param encoders the array of client-specific encoders for tinting. + */ +public class PlayerTintingList( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public val global: Tinting = + Tinting() + public val observerDependent: + MutableMap = HashMap() + + public operator fun get(index: Int): Tinting = observerDependent.getOrDefault(index, global) + + override fun clear() { + releaseBuffers() + global.reset() + if (observerDependent.isNotEmpty()) { + observerDependent.clear() + } + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/TemporaryMoveSpeed.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/TemporaryMoveSpeed.kt new file mode 100644 index 000000000..fe182a2aa --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/playerinfo/extendedinfo/TemporaryMoveSpeed.kt @@ -0,0 +1,27 @@ +package net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The temporary move speed is used to set a move speed for a single cycle, commonly done + * when the player has run enabled through the orb, but decides to only walk a single tile instead. + * Rather than to switch the main mode over to walking, it utilizes the temporary move speed + * so the primary one will remain as running after this one cycle, as they are far more likely + * to utilize the move speed described by their run orb. + * @param encoders the array of client-specific encoders for temporary move speed. + */ +public class TemporaryMoveSpeed( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The movement speed of this avatar for a single cycle. + */ + public var value: Int = -1 + + override fun clear() { + releaseBuffers() + value = -1 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/ExactMove.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/ExactMove.kt new file mode 100644 index 000000000..d21b3b691 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/ExactMove.kt @@ -0,0 +1,68 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The exactmove extended info block is used to provide precise fine-tuned visual movement + * of an avatar. + * @param encoders the array of client-specific encoders for exact move. + */ +public class ExactMove( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The coordinate delta between the current absolute + * x coordinate and where the avatar is going. + */ + public var deltaX1: UByte = 0u + + /** + * The coordinate delta between the current absolute + * z coordinate and where the avatar is going. + */ + public var deltaZ1: UByte = 0u + + /** + * Delay1 defines how many client cycles (20ms/cc) until the avatar arrives + * at x/z 1 coordinate. + */ + public var delay1: UShort = 0u + + /** + * The coordinate delta between the current absolute + * x coordinate and where the avatar is going. + */ + public var deltaX2: UByte = 0u + + /** + * The coordinate delta between the current absolute + * z coordinate and where the avatar is going. + */ + public var deltaZ2: UByte = 0u + + /** + * Delay2 defines how many client cycles (20ms/cc) until the avatar arrives + * at x/z 2 coordinate. + */ + public var delay2: UShort = 0u + + /** + * The angle the avatar will be facing throughout the exact movement, + * with 0 implying south, 512 west, 1024 north and 1536 east; interpolate + * between to get finer directions. + */ + public var direction: UShort = 0u + + override fun clear() { + releaseBuffers() + deltaX1 = 0u + deltaZ1 = 0u + delay1 = 0u + deltaX2 = 0u + deltaZ2 = 0u + delay2 = 0u + direction = 0u + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FaceAngle.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FaceAngle.kt new file mode 100644 index 000000000..e8ceaf84d --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FaceAngle.kt @@ -0,0 +1,48 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The extended info block responsible for making an avatar turn towards a specific + * angle. + * @param encoders the array of client-specific encoders for face angle. + */ +public class FaceAngle( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The value of the angle for this avatar to turn towards. + */ + public var angle: UShort = UShort.MAX_VALUE + public var instant: Boolean = DEFAULT_INSTANT + + public var outOfDate: Boolean = false + private set + + public fun markUpToDate() { + if (!outOfDate) { + return + } + outOfDate = false + releaseBuffers() + } + + public fun syncAngle(angle: Int) { + this.outOfDate = true + this.angle = angle.toUShort() + } + + override fun clear() { + releaseBuffers() + angle = UShort.MAX_VALUE + instant = DEFAULT_INSTANT + outOfDate = false + } + + public companion object { + public val DEFAULT_VALUE: UShort = UShort.MAX_VALUE + public const val DEFAULT_INSTANT: Boolean = false + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FacePathingEntity.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FacePathingEntity.kt new file mode 100644 index 000000000..da3b16fc1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/FacePathingEntity.kt @@ -0,0 +1,28 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The extended info block to make avatars face-lock onto another avatar, be it a NPC or a player. + * @param encoders the array of client-specific encoders for face pathing entity. + */ +public class FacePathingEntity( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The index of the avatar to face-lock onto. For player avatars, + * a value of 0x10000 is added onto the index to differentiate it. + */ + public var index: Int = DEFAULT_VALUE + + override fun clear() { + releaseBuffers() + index = DEFAULT_VALUE + } + + public companion object { + public const val DEFAULT_VALUE: Int = -1 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Hit.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Hit.kt new file mode 100644 index 000000000..7a5020609 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Hit.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HeadBarList +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HitMarkList + +/** + * The hit extended info, responsible for tracking all hitmarks and headbars for a given avatar. + * @param encoders the array of client-specific encoders for hits. + */ +public class Hit( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public val headBarList: HeadBarList = + HeadBarList() + public val hitMarkList: HitMarkList = + HitMarkList() + + override fun clear() { + releaseBuffers() + headBarList.clear() + hitMarkList.clear() + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Say.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Say.kt new file mode 100644 index 000000000..f0a68a8dc --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Say.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The say extended info block tracks any overhead chat set by the server, + * through content. Public chat will not utilize this. + * @param encoders the array of client-specific encoders for say. + */ +public class Say( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The text to render over the avatar. + */ + public var text: String? = null + + override fun clear() { + releaseBuffers() + text = null + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Sequence.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Sequence.kt new file mode 100644 index 000000000..5f7c68cfc --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Sequence.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +/** + * The sequence mask defines what animation an avatar is playing. + * @param encoders the array of client-specific encoders for sequence. + */ +public class Sequence( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The id of the animation to play. + */ + public var id: UShort = 0xFFFFu + + /** + * The delay in client cycles (20ms/cc) until the given animation begins playing. + */ + public var delay: UShort = 0u + + override fun clear() { + releaseBuffers() + id = 0xFFFFu + delay = 0u + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/SpotAnimList.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/SpotAnimList.kt new file mode 100644 index 000000000..a4dcecf6d --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/SpotAnimList.kt @@ -0,0 +1,69 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.SpotAnim +import java.util.BitSet + +/** + * The spotanim list is a specialized extended info block with compression logic built into it, + * as the theoretical possibilities of this block are rather large. + * This extended info block will track the modified slots using a bitset. + * Instead of traversing the entire list at the end of a cycle to reset the properties, + * it will follow the bitset's enabled bits to identify which slots to reset, if any. + * As in most cases the answer is none - this should outperform array fills by quite a bit. + * @param encoders the array of client-specific encoders for spotanims. + */ +public class SpotAnimList( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + /** + * The changelist that tracks all the slots which have been flagged for a spotanim update. + */ + public val changelist: BitSet = BitSet(RSProtFlags.spotanimListCapacity) + + /** + * The array of spotanims on this avatar. + * This array utilizes the bitpacked representation of a [SpotAnim]. + */ + public val spotanims: LongArray = + LongArray(RSProtFlags.spotanimListCapacity) { + UNINITIALIZED_SPOTANIM + } + + /** + * Sets the spotanim in slot [slot]. + * This function will also flag the given slot for a change. + * @param slot the slot of the spotanim to set. + * @param spotAnim the spotanim to set. + */ + public fun set( + slot: Int, + spotAnim: SpotAnim, + ) { + spotanims[slot] = spotAnim.packed + changelist.set(slot) + } + + /** + * Traverses the bit set to determine which spotanims to clear out. + */ + override fun clear() { + releaseBuffers() + var nextSetBit = changelist.nextSetBit(0) + if (nextSetBit == -1) { + return + } + do { + spotanims[nextSetBit] = UNINITIALIZED_SPOTANIM + nextSetBit = changelist.nextSetBit(nextSetBit + 1) + } while (nextSetBit != -1) + changelist.clear() + } + + public companion object { + private const val UNINITIALIZED_SPOTANIM = -1L + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Tinting.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Tinting.kt new file mode 100644 index 000000000..c28d3887a --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/Tinting.kt @@ -0,0 +1,47 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +/** + * The tinting class is used to apply a specific tint onto the non-textured parts of + * an avatar. As the engine does not support modifying textures this way, they remain + * in their original form. + */ +public class Tinting { + /** + * The delay in client cycles (20ms/cc) until the tinting is applied. + */ + public var start: UShort = 0u + + /** + * The timestamp in client cycles (20ms/cc) until the tinting finishes. + */ + public var end: UShort = 0u + + /** + * The hue of the tint. + */ + public var hue: UByte = 0u + + /** + * The saturation of the tint. + */ + public var saturation: UByte = 0u + + /** + * The lightness of the tint. + */ + public var lightness: UByte = 0u + + /** + * The weight (or opacity) of the tint. + */ + public var weight: UByte = 0u + + public fun reset() { + start = 0u + end = 0u + hue = 0u + saturation = 0u + lightness = 0u + weight = 0u + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/VisibleOps.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/VisibleOps.kt new file mode 100644 index 000000000..729ad4982 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/VisibleOps.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo + +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.TransientExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder + +public class VisibleOps( + override val encoders: ClientTypeMap>, +) : TransientExtendedInfo>() { + public var ops: UByte = DEFAULT_OPS + + override fun clear() { + releaseBuffers() + ops = DEFAULT_OPS + } + + public companion object { + public const val DEFAULT_OPS: UByte = 0b11111u + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBar.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBar.kt new file mode 100644 index 000000000..880e703e9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBar.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util + +/** + * A class to hold the values of a given head bar. + * @param sourceIndex the index of the entity that dealt the hit that resulted in this headbar. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for rendering purposes, as both the player who dealt + * the hit, and the recipient will see the [selfType] variant, and everyone else + * will see the [otherType] variant, which, if set to -1 will be skipped altogether. + * @param selfType the id of the headbar to render to the entity on which the headbar appears, + * as well as the source who resulted in the creation of the headbar. + * @param otherType the id of the headbar to render to everyone that doesn't fit the [selfType] + * criteria. If set to -1, the headbar will not be rendered to these individuals. + * @param startFill the number of pixels to render of this headbar at in the start. + * The number of pixels that a headbar supports is defined in its respective headbar config. + * @param endFill the number of pixels to render of this headbar at in the end, + * if a [startTime] and [endTime] are defined. + * @param startTime the delay in client cycles (20ms/cc) until the headbar renders at [startFill] + * @param endTime the delay in client cycles (20ms/cc) until the headbar arrives at [endFill]. + */ +public data class HeadBar( + public var sourceIndex: Int, + public var selfType: UShort, + public var otherType: UShort, + public val startFill: UByte, + public val endFill: UByte, + public val startTime: UShort, + public val endTime: UShort, +) { + public companion object { + /** + * A constant that informs the client to remove the headbar by the given id. + */ + public const val REMOVED: UShort = 0x7FFFu + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBarList.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBarList.kt new file mode 100644 index 000000000..59ed0c778 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HeadBarList.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util + +/** + * The headbar list will contain all the headbars for this given avatar for a single cycle, + * backed by an [ArrayList]. + */ +public class HeadBarList( + private val elements: MutableList, +) : MutableList by elements { + public constructor( + capacity: Int = DEFAULT_CAPACITY, + ) : this(ArrayList(capacity)) + + private companion object { + /** + * The default capacity for the hits. + * As most avatars will not be getting hit much, there isn't much reason to + * allocate a large capacity list ahead of time. + */ + private const val DEFAULT_CAPACITY = 10 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMark.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMark.kt new file mode 100644 index 000000000..0aa7c08a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMark.kt @@ -0,0 +1,80 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util + +/** + * A class for holding all the state of a given hitmark. + * @param sourceIndex the index of the character that dealt the hit. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for tinting purposes, as both the player who dealt + * the hit, and the recipient will see a tinted variant. + * Everyone else, however, will see a regular darkened hit mark. + * @param sourceType the multi hitmark id that supports tinted and darkened variants, shown to the player + * with the index of [sourceIndex]. + * @param selfType the multi hitmark id that supports tinted and darkened variants, shown to the player + * that receives the hit. + * @param otherType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * If the hitmark should only render to the local player, set the [otherType] + * value to -1, forcing it to only render to the recipient (and in the case of + * a [sourceIndex] being defined, the one who dealt the hit) + * @param value the value to show over the hitmark. + * @param sourceSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. This one renders to the one who dealt the hit. + * @param selfSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. This one renders to the recipient of the hit. + * @param otherSoakType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. This one renders to + * everyone but the source and recipient of the hit. + * Unlike the [otherType], this does not support -1, as it is not possible to show partial + * soaked hitmarks. + * @param delay the delay in client cycles (20ms/cc) until the hitmark renders. + */ +public class HitMark( + public var sourceIndex: Int, + public var sourceType: UShort, + public var selfType: UShort, + public var otherType: UShort, + public var value: UShort, + public var sourceSoakType: UShort, + public var selfSoakType: UShort, + public var otherSoakType: UShort, + public var soakValue: UShort, + public var delay: UShort, +) { + public constructor( + sourceIndex: Int, + sourceType: UShort, + selfType: UShort, + otherType: UShort, + value: UShort, + delay: UShort, + ) : this( + sourceIndex = sourceIndex, + sourceType = sourceType, + selfType = selfType, + otherType = otherType, + value = value, + sourceSoakType = UShort.MAX_VALUE, + selfSoakType = UShort.MAX_VALUE, + otherSoakType = UShort.MAX_VALUE, + soakValue = UShort.MAX_VALUE, + delay = delay, + ) + + public constructor( + selfType: UShort, + delay: UShort, + ) : this( + sourceIndex = -1, + sourceType = selfType, + selfType = selfType, + otherType = selfType, + value = UShort.MAX_VALUE, + sourceSoakType = UShort.MAX_VALUE, + selfSoakType = UShort.MAX_VALUE, + otherSoakType = UShort.MAX_VALUE, + soakValue = UShort.MAX_VALUE, + delay = delay, + ) +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMarkList.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMarkList.kt new file mode 100644 index 000000000..6cd874df0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/HitMarkList.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util + +/** + * The hitmark list will contain all the hitmarks for this given avatar for a single cycle, + * backed by an [ArrayList]. + */ +public class HitMarkList( + private val elements: MutableList, +) : MutableList by elements { + public constructor( + capacity: Int = DEFAULT_CAPACITY, + ) : this(ArrayList(capacity)) + + private companion object { + private const val DEFAULT_CAPACITY = 10 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/SpotAnim.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/SpotAnim.kt new file mode 100644 index 000000000..54024f5ed --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/shared/extendedinfo/util/SpotAnim.kt @@ -0,0 +1,39 @@ +package net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util + +/** + * A value class to represent a spotanim in a primitive 'long'. + * @param packed the bitpacked long value of this spotanim. + */ +@JvmInline +public value class SpotAnim( + internal val packed: Long, +) { + /** + * @param id the id of the spotanim. + * @param delay the delay in client cycles (20ms/cc) until the given spotanim begins rendering. + * @param height the height at which to render the spotanim. + */ + public constructor( + id: Int, + delay: Int, + height: Int, + ) : this( + (id.toLong() and 0xFFFF) + .or(delay.toLong() and 0xFFFF shl 16) + .or(height.toLong() and 0xFFFF shl 32), + ) + + public val id: Int + get() = (packed and 0xFFFF).toInt() + public val delay: Int + get() = (packed ushr 16 and 0xFFFF).toInt() + public val height: Int + get() = (packed ushr 32 and 0xFFFF).toInt() + + public companion object { + /** + * The default value to initialize spotanim extended info as. + */ + public val INVALID: SpotAnim = SpotAnim(-1L) + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/util/ZoneIndexArray.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/util/ZoneIndexArray.kt new file mode 100644 index 000000000..a62add593 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/util/ZoneIndexArray.kt @@ -0,0 +1,68 @@ +package net.rsprot.protocol.internal.game.outgoing.info.util + +/** + * A super-unsafe internal storage class for NPCs and WorldEntities. + * + * This class is only intended to be used within the RSProt library, as the functions + * it offers do not perform any validation and rely on correctness of use by the + * library itself. This is done in order to improve performance. + * @property array the array of indices. This array will only ever grow in size, never shrink. + * Whenever elements are removed, their value is overwritten with [FREE_INDEX]. + * The respective [ZoneIndexStorage] will delete this entire object if the index count in here + * falls to zero, in order to avoid memory growing indefinitely. + * @property size the current number of elements in the array. + */ +internal class ZoneIndexArray( + var array: ShortArray, + var size: Int, +) { + constructor() : this( + ShortArray(2) { + FREE_INDEX + }, + 0, + ) + + /** + * Adds the [index] to the array. If the array is full, it gets doubled in size before + * the entry is added in. + * @param index the index to add to the array, must be a value in range of 0..65534. + */ + fun add(index: Int) { + // If our array is full, double the capacity + if (array.size == size) { + val copy = + ShortArray(array.size * 2) { + FREE_INDEX + } + array.copyInto(copy) + this.array = copy + } + array[size++] = index.toShort() + } + + /** + * Removes the [index] from the array, or throws an [IllegalArgumentException] if it does not exist. + * @param index the index to remove from this array. + * @throws IllegalArgumentException if the index is not within the array. + */ + fun remove(index: Int) { + val indexAsShort = index.toShort() + for (i in 0..(expected) + + /** + * Moves the [entityIndex] from the zone at [from] to the zone at [to] if the zones differ. + * If the zones are equal, no change is performed. + */ + public fun move( + entityIndex: Int, + from: CoordGrid, + to: CoordGrid, + ) { + val fromZoneIndex = zoneIndex(from) + val toZoneIndex = zoneIndex(to) + if (fromZoneIndex != toZoneIndex) { + remove(entityIndex, fromZoneIndex) + add(entityIndex, toZoneIndex) + } + } + + /** + * Adds the [entityIndex] to the zone that contains the [coordGrid] coord. + * @param entityIndex the index to add to the end of the zone. + * @param coordGrid the coord grid at which to find the zone array. + * If the zone does not exist, a new instance is created. + */ + public fun add( + entityIndex: Int, + coordGrid: CoordGrid, + ) { + val zoneIndex = zoneIndex(coordGrid) + add(entityIndex, zoneIndex) + } + + /** + * Adds the [entityIndex] to the zone with the provided index. + * @param entityIndex the index to add to the end of the zone. + * @param zoneIndex the index of the zone to add into. + */ + public fun add( + entityIndex: Int, + zoneIndex: Int, + ) { + var array = dictionary.get(zoneIndex) + if (array == null) { + array = + ZoneIndexArray() + array.add(entityIndex) + dictionary.put(zoneIndex, array) + return + } + array.add(entityIndex) + } + + /** + * Removes the [entityIndex] at the zone located at [coordGrid]. + * @param entityIndex the index to remove from the zone. + * @param coordGrid the coord grid at which the zone exists. + * If there's only one index remaining in the respective zone index array, + * the zone index array will be disposed. + */ + public fun remove( + entityIndex: Int, + coordGrid: CoordGrid, + ) { + val zoneIndex = zoneIndex(coordGrid) + remove(entityIndex, zoneIndex) + } + + /** + * Removes the [entityIndex] at the zone with the index [zoneIndex]. + * @param entityIndex the index to remove from the zone. + * @param zoneIndex the zone index to remove the entity from. + */ + public fun remove( + entityIndex: Int, + zoneIndex: Int, + ) { + val array = + checkNotNull(dictionary.get(zoneIndex)) { + "Array not found for zone index: $zoneIndex" + } + if (array.size == 1) { + dictionary.remove(zoneIndex) + return + } + array.remove(entityIndex) + } + + /** + * Gets the array of indices at the provided zone coordinate (not to be confused with + * coord grids). + * @param level the level at which the zone is + * @param zoneX the zone x coordinate, obtained via coordGrid.x shr 3 + * @param zoneZ the zone z coordinate, obtained via coordGrid.z shr 3 + * @return a short array containing the indices of all the elements in the array. + * The array may contain 65535 values at the end, which should be ignored as they + * imply an open spot for future additions. + */ + public fun get( + level: Int, + zoneX: Int, + zoneZ: Int, + ): ShortArray? { + val zoneIndex = + (level shl 22) + .or(zoneX shl 11) + .or(zoneZ) + return dictionary.get(zoneIndex)?.array + } + + /** + * Bitpacks the zone index based on the [coordGrid]. + * @param coordGrid the coord grid from which to calculate the zone index. + * @return the bitpacked zone index. + */ + private fun zoneIndex(coordGrid: CoordGrid): Int { + val level = coordGrid.level + val zoneX = coordGrid.x ushr 3 + val zoneZ = coordGrid.z ushr 3 + return (level shl 22) + .or(zoneX shl 11) + .or(zoneZ) + } + + public companion object { + public const val WORLDENTITY_CAPACITY: Int = 4096 + public const val NPC_CAPACITY: Int = 65536 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/worldentityinfo/encoder/WorldEntityExtendedInfoEncoders.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/worldentityinfo/encoder/WorldEntityExtendedInfoEncoders.kt new file mode 100644 index 000000000..c58f16052 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/info/worldentityinfo/encoder/WorldEntityExtendedInfoEncoders.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.internal.game.outgoing.info.worldentityinfo.encoder + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.encoder.PrecomputedExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps + +/** + * A data class to bring all the extended info encoders for a given client together. + * @param oldSchoolClientType the client for which these encoders are created. + * @param visibleOps the enabled/visible ops when right-clicking a world entity + */ +public data class WorldEntityExtendedInfoEncoders( + public val oldSchoolClientType: OldSchoolClientType, + public val sequence: PrecomputedExtendedInfoEncoder, + public val visibleOps: PrecomputedExtendedInfoEncoder, +) diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/Inventory.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/Inventory.kt new file mode 100644 index 000000000..37352e4a6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/Inventory.kt @@ -0,0 +1,61 @@ +package net.rsprot.protocol.internal.game.outgoing.inv.internal + +import net.rsprot.protocol.common.game.outgoing.inv.InventoryObject +import kotlin.jvm.Throws + +/** + * A compressed internal representation of an inventory, to be transmitted + * with the various inventory update packets. + * Rather than use a List, we pool these [Inventory] instances to avoid + * generating significant amounts of garbage. + * For a popular server, it is perfectly reasonable to expect north of a gigabyte + * of memory to be wasted through List instances in the span of an hour. + * We eliminate all garbage generation by using soft-reference pooled inventory + * objects. While this does result in a small hit due to the synchronization involved, + * it is nothing compared to the hit caused by garbage collection and memory allocation + * involved with inventories. + * + * @property count the current count of objs in this inventory + * @property contents the array of contents of this inventory. + * The contents array is initialized at the maximum theoretical size + * of the full inv update packet. + */ +public class Inventory private constructor( + public var count: Int, + private val contents: LongArray, +) { + public constructor( + capacity: Int, + ) : this( + 0, + LongArray(capacity), + ) + + /** + * Adds an obj into this inventory + * @param obj the obj to be added to this inventory + * @throws ArrayIndexOutOfBoundsException if the inventory is full + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun add(obj: Long) { + contents[count++] = obj + } + + /** + * Gets the obj in [slot]. + * @return the obj in the respective slot, or [InventoryObject.NULL] + * if no object exists in that slot. + * @throws ArrayIndexOutOfBoundsException if the index is out of bounds + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public operator fun get(slot: Int): Long = contents[slot] + + /** + * Clears the inventory by setting the count to zero. + * The actual backing long array can remain filled with values, + * as those will be overridden by real usages whenever necessary. + */ + public fun clear() { + count = 0 + } +} diff --git a/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/InventoryPool.kt b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/InventoryPool.kt new file mode 100644 index 000000000..cf59f5bf5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-internal/src/main/kotlin/net/rsprot/protocol/internal/game/outgoing/inv/internal/InventoryPool.kt @@ -0,0 +1,51 @@ +package net.rsprot.protocol.internal.game.outgoing.inv.internal + +import org.apache.commons.pool2.BasePooledObjectFactory +import org.apache.commons.pool2.ObjectPool +import org.apache.commons.pool2.PooledObject +import org.apache.commons.pool2.PooledObjectFactory +import org.apache.commons.pool2.impl.DefaultPooledObject +import org.apache.commons.pool2.impl.SoftReferenceObjectPool + +/** + * A soft-reference based pool of [Inventory] objects, with the primary + * intent being to avoid re-creating lists of objs which end up wasting + * as much as 137kb of memory for a single inventory that's up to 5713 objs + * in capacity. While it is unlikely that any inventory would get near that, + * servers do commonly expand inventory capacities to numbers like 2,000 or 2,500, + * which would still consume up 48-60kb of memory as a result in any traditional manner. + * + * Breakdown of the above statements: + * Assuming an implementation where List is provided to the respective packets, + * where Obj is a class of three properties: + * + * ``` + * Slot: Int (necessary for partial inv updates) + * Id: Int + * Count: Int + * ``` + * + * The resulting memory requirement would be `(12 + (3 * 4))` bytes per obj. + * While this does coincide with the memory alignment, + * it still ends up consuming 24 bytes per obj, all of which would be discarded shortly after. + * Given the assumption that 1,000 players log in at once, and they all have a bank + * of 1000 objs - which is a fairly conservative estimate -, the resulting waste of memory + * is 24 megabytes alone. All of this can be avoided through the use of an object pool, + * as done below. + */ +public data object InventoryPool { + public val pool: ObjectPool = SoftReferenceObjectPool(createFactory()) + + private fun createFactory(): PooledObjectFactory { + return object : BasePooledObjectFactory() { + override fun create(): Inventory { + // 5713 is the maximum theoretical number of objs an inventory can carry + // before the 40kb limitation could get hit + // This assumes each obj sends a quantity of >= 255 + return Inventory(5713) + } + + override fun wrap(p0: Inventory): PooledObject = DefaultPooledObject(p0) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/build.gradle.kts b/protocol/osrs-236/osrs-236-model/build.gradle.kts new file mode 100644 index 000000000..548cc0bab --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + api(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + implementation(rootProject.libs.inline.logger) + api(rootProject.libs.commons.pool2) + api(projects.buffer) + api(projects.compression) + api(projects.crypto) + api(projects.protocol) + api(projects.protocol.osrs236.osrs236Internal) + api(projects.protocol.osrs236.osrs236Common) + implementation(libs.fastutil) +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 Model" + description = "The model module for revision 236 OldSchool RuneScape networking, " + + "offering all the model classes to be used by the implementing server." + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/GameClientProtCategory.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/GameClientProtCategory.kt new file mode 100644 index 000000000..99e8d3d70 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/GameClientProtCategory.kt @@ -0,0 +1,10 @@ +package net.rsprot.protocol.game.incoming + +import net.rsprot.protocol.ClientProtCategory + +public enum class GameClientProtCategory( + override val id: Int, +) : ClientProtCategory { + CLIENT_EVENT(0), + USER_EVENT(1), +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If1Button.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If1Button.kt new file mode 100644 index 000000000..f29bd3463 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If1Button.kt @@ -0,0 +1,43 @@ +package net.rsprot.protocol.game.incoming.buttons + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If1 button messages are sent whenever a player clicks on an older + * if1-type component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id the player interacted with + * @property componentId the component id on that interface the player interacted with + */ +public class If1Button( + private val _combinedId: CombinedId, +) : IncomingGameMessage { + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as If1Button + + return _combinedId == other._combinedId + } + + override fun hashCode(): Int = _combinedId.hashCode() + + override fun toString(): String = + "If1Button(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If3Button.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If3Button.kt new file mode 100644 index 000000000..4030f9df3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/If3Button.kt @@ -0,0 +1,83 @@ +package net.rsprot.protocol.game.incoming.buttons + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * If3 button messages are sent whenever a player clicks on a newer + * if3-type component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id the player interacted with + * @property componentId the component id on that interface the player interacted with + * @property sub the subcomponent within that component if it has one, otherwise -1 + * @property obj the obj in that subcomponent, or -1 + * @property op the option clicked, ranging from 1 to 32 + */ +@Suppress("MemberVisibilityCanBePrivate") +public class If3Button private constructor( + private val _combinedId: CombinedId, + private val _sub: UShort, + private val _obj: UShort, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + combinedId: CombinedId, + sub: Int, + obj: Int, + op: Int, + ) : this( + combinedId, + sub.toUShort(), + obj.toUShort(), + op.toUByte(), + ) + + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val sub: Int + get() = _sub.toIntOrMinusOne() + public val obj: Int + get() = _obj.toIntOrMinusOne() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as If3Button + + if (_combinedId != other._combinedId) return false + if (_sub != other._sub) return false + if (_obj != other._obj) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _combinedId.hashCode() + result = 31 * result + _sub.hashCode() + result = 31 * result + _obj.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "If3Button(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "sub=$sub, " + + "obj=$obj, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonD.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonD.kt new file mode 100644 index 000000000..8b5c01cef --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonD.kt @@ -0,0 +1,113 @@ +package net.rsprot.protocol.game.incoming.buttons + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * If button drag messages are sent whenever an obj is dragged from one subcomponent + * to another. + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id from which the obj is dragged + * @property selectedComponentId the component on that selected interface from which + * the obj is dragged + * @property selectedSub the subcomponent from which the obj is dragged, + * or -1 if none exists + * @property selectedObj the obj that is being dragged, or -1 if none exists + * @property targetCombinedId the bitpacked combination of [targetInterfaceId] and [targetComponentId]. + * @property targetInterfaceId the interface id to which the obj is being dragged + * @property targetComponentId the component of the target interface to which + * the obj is being dragged + * @property targetSub the subcomponent of the target to which the obj is being dragged, + * or -1 if none exists + * @property targetObj the obj in that subcomponent which is being dragged on, + * or -1 if there is no obj in the target position + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class IfButtonD private constructor( + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, + private val _targetCombinedId: CombinedId, + private val _targetSub: UShort, + private val _targetObj: UShort, +) : IncomingGameMessage { + public constructor( + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + targetCombinedId: CombinedId, + targetSub: Int, + targetObj: Int, + ) : this( + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + targetCombinedId, + targetSub.toUShort(), + targetObj.toUShort(), + ) + + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + public val targetCombinedId: Int + get() = _targetCombinedId.combinedId + public val targetInterfaceId: Int + get() = _targetCombinedId.interfaceId + public val targetComponentId: Int + get() = _targetCombinedId.componentId + public val targetSub: Int + get() = _targetSub.toIntOrMinusOne() + public val targetObj: Int + get() = _targetObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfButtonD + + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + if (_targetCombinedId != other._targetCombinedId) return false + if (_targetSub != other._targetSub) return false + if (_targetObj != other._targetObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + result = 31 * result + _targetCombinedId.hashCode() + result = 31 * result + _targetSub.hashCode() + result = 31 * result + _targetObj.hashCode() + return result + } + + override fun toString(): String = + "IfButtonD(" + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj, " + + "targetInterfaceId=$targetInterfaceId, " + + "targetComponentId=$targetComponentId, " + + "targetSub=$targetSub, " + + "targetObj=$targetObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonT.kt new file mode 100644 index 000000000..899f56ec5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfButtonT.kt @@ -0,0 +1,110 @@ +package net.rsprot.protocol.game.incoming.buttons + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * If button target messages are used whenever one button is targeted against another. + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the selected interface id of the component that is being used + * @property selectedComponentId the selected component id that is being used + * @property selectedSub the subcomponent id of the selected, or -1 if none exists + * @property selectedObj the obj in the selected subcomponent, or -1 if none exists + * @property targetCombinedId the bitpacked combination of [targetInterfaceId] and [targetComponentId]. + * @property targetInterfaceId the target interface id on which the selected component + * is being used + * @property targetComponentId the target component id on which the selected component + * is being used + * @property targetSub the target subcomponent id on which the selected component is + * being used, or -1 if none exists + * @property targetObj the obj within the target subcomponent, or -1 if none exists. + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class IfButtonT private constructor( + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, + private val _targetCombinedId: CombinedId, + private val _targetSub: UShort, + private val _targetObj: UShort, +) : IncomingGameMessage { + public constructor( + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + targetCombinedId: CombinedId, + targetSub: Int, + targetObj: Int, + ) : this( + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + targetCombinedId, + targetSub.toUShort(), + targetObj.toUShort(), + ) + + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + public val targetCombinedId: Int + get() = _targetCombinedId.combinedId + public val targetInterfaceId: Int + get() = _targetCombinedId.interfaceId + public val targetComponentId: Int + get() = _targetCombinedId.componentId + public val targetSub: Int + get() = _targetSub.toIntOrMinusOne() + public val targetObj: Int + get() = _targetObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfButtonT + + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + if (_targetCombinedId != other._targetCombinedId) return false + if (_targetSub != other._targetSub) return false + if (_targetObj != other._targetObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + result = 31 * result + _targetCombinedId.hashCode() + result = 31 * result + _targetSub.hashCode() + result = 31 * result + _targetObj.hashCode() + return result + } + + override fun toString(): String = + "IfButtonT(" + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj, " + + "targetInterfaceId=$targetInterfaceId, " + + "targetComponentId=$targetComponentId, " + + "targetSub=$targetSub, " + + "targetObj=$targetObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfRunScript.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfRunScript.kt new file mode 100644 index 000000000..b4a911a0f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfRunScript.kt @@ -0,0 +1,197 @@ +package net.rsprot.protocol.game.incoming.buttons + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.buffer.extensions.g1 +import net.rsprot.buffer.extensions.gSmart1or2 +import net.rsprot.buffer.extensions.gVarInt2s +import net.rsprot.buffer.extensions.gjstr +import net.rsprot.buffer.extensions.toByteArray +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * IfRunScript is used by the client to run a server script that is associated with the component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id the player interacted with + * @property componentId the component id on that interface the script is associated to + * @property sub the subcomponent within that component if it has one, otherwise -1 + * @property obj the obj in that subcomponent, or -1 + * @property script the id of the server script to invoke + * @property buffer the byte buffer containing the values sent by the packet. + * As the packet itself does not define the types, it isn't possible to immediately decode the + * buffer as with most packets. We require the server to provide the instructions on how the + * buffer should be decoded for the provided server script id. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class IfRunScript private constructor( + private val _combinedId: CombinedId, + private val _sub: UShort, + private val _obj: UShort, + public val script: Int, + public val buffer: ByteBuf, +) : DefaultByteBufHolder(buffer), + IncomingGameMessage { + public constructor( + combinedId: CombinedId, + sub: Int, + obj: Int, + script: Int, + buffer: ByteBuf, + ) : this( + combinedId, + sub.toUShort(), + obj.toUShort(), + script, + buffer, + ) + + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val sub: Int + get() = _sub.toIntOrMinusOne() + public val obj: Int + get() = _obj.toIntOrMinusOne() + + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + /** + * Decodes the script's arguments using the provided parameter types. + * The buffer is always released after this function is invoked. It should be invoked + * even if there are no parameters to decode, to release the empty buffer. + * @param parameterTypes the parameter types to decode the script with. These must match + * the ones client used to encode the packet, or an exception will be thrown. + * @return a list of arguments passed on by the client. + */ + public fun decode(parameterTypes: ParameterTypes): Arguments { + val buffer = this.buffer + check(buffer.refCnt() > 0) { + "Buffer has already been released." + } + + try { + val args = + parameterTypes.chars.mapIndexed { index, char -> + when (char) { + ParameterTypes.INT_ARRAY -> { + val length = buffer.gSmart1or2() + IntArray(length) { + buffer.gVarInt2s() + } + } + ParameterTypes.STRING_ARRAY -> { + val length = buffer.gSmart1or2() + Array(length) { + buffer.gjstr() + } + } + ParameterTypes.STRING -> { + buffer.gjstr() + } + ParameterTypes.INT -> { + buffer.gVarInt2s() + } + ParameterTypes.NULL -> { + buffer.g1() + } + else -> { + throw IllegalStateException("Invalid parameter type in index $index: $char") + } + } + } + check(!buffer.isReadable) { + "Buffer still has bytes in it after decoding: ${buffer.readableBytes()} " + + "(values: ${buffer.toByteArray().contentToString()})" + } + return Arguments(args) + } finally { + buffer.release() + } + } + + /** + * A helper class to wrap server script parameter types, as the client does not transmit the types. + * The companion object has helpers for constructing the script values. + */ + public data class ParameterTypes( + public val chars: CharArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ParameterTypes + + return chars.contentEquals(other.chars) + } + + override fun hashCode(): Int { + return chars.contentHashCode() + } + + public companion object { + public const val INT_ARRAY: Char = 'W' + public const val STRING_ARRAY: Char = 'X' + public const val STRING: Char = 's' + public const val INT: Char = 'i' + public const val NULL: Char = 0.toChar() + + public val NONE: ParameterTypes = ParameterTypes(charArrayOf()) + + /** + * A var-arg based helper for constructing parameter types. + * Users should use the constants defined in this object + * to declare the types to decode the script with. + */ + @JvmStatic + public fun of(vararg type: Char): ParameterTypes { + return ParameterTypes(type) + } + } + } + + public data class Arguments( + public val args: List, + ) : List by args + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfRunScript + + if (_combinedId != other._combinedId) return false + if (_sub != other._sub) return false + if (_obj != other._obj) return false + if (script != other.script) return false + if (buffer != other.buffer) return false + + return true + } + + override fun hashCode(): Int { + var result = _combinedId.hashCode() + result = 31 * result + _sub.hashCode() + result = 31 * result + _obj.hashCode() + result = 31 * result + script.hashCode() + result = 31 * result + buffer.hashCode() + return result + } + + override fun toString(): String = + "IfRunScript(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "sub=$sub, " + + "obj=$obj, " + + "script=$script" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfSubOp.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfSubOp.kt new file mode 100644 index 000000000..9459369bb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/buttons/IfSubOp.kt @@ -0,0 +1,89 @@ +package net.rsprot.protocol.game.incoming.buttons + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * Ifsubop messages are sent when the player clicks on a submenu option. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id the player interacted with + * @property componentId the component id on that interface the player interacted with + * @property sub the subcomponent within that component if it has one, otherwise -1 + * @property obj the obj in that subcomponent, or -1 + * @property op the option clicked, ranging from 1 to 10 + * @property subop the submenu option clicked + */ +@Suppress("MemberVisibilityCanBePrivate") +public class IfSubOp private constructor( + private val _combinedId: CombinedId, + private val _sub: UShort, + private val _obj: UShort, + private val _op: UByte, + private val _subop: UByte, +) : IncomingGameMessage { + public constructor( + combinedId: CombinedId, + sub: Int, + obj: Int, + op: Int, + subop: Int, + ) : this( + combinedId, + sub.toUShort(), + obj.toUShort(), + op.toUByte(), + subop.toUByte(), + ) + + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val sub: Int + get() = _sub.toIntOrMinusOne() + public val obj: Int + get() = _obj.toIntOrMinusOne() + public val op: Int + get() = _op.toInt() + public val subop: Int + get() = _subop.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSubOp + + if (_combinedId != other._combinedId) return false + if (_sub != other._sub) return false + if (_obj != other._obj) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _combinedId.hashCode() + result = 31 * result + _sub.hashCode() + result = 31 * result + _obj.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "IfSubOp(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "sub=$sub, " + + "obj=$obj, " + + "op=$op, " + + "subop=$subop" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsAddBannedFromChannel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsAddBannedFromChannel.kt new file mode 100644 index 000000000..ba9052da0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsAddBannedFromChannel.kt @@ -0,0 +1,67 @@ +package net.rsprot.protocol.game.incoming.clan + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Clan ban messages are sent when a player with sufficient rank + * in the clan requests to ban another member within the clan. + * @property name the name of the player to ban + * @property clanId the id of the clan, ranging from 0 to 3 (inclusive). + * Negative values are not supported for bans - it is not possible to + * ban others while you are in a clan as a guest. + * @property memberIndex the index of the member in the clan who's being banned. + * Note that the index isn't the player's absolute index in the world, but rather + * the index within this clan. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class AffinedClanSettingsAddBannedFromChannel private constructor( + public val name: String, + private val _clanId: UByte, + private val _memberIndex: UShort, +) : IncomingGameMessage { + public constructor( + name: String, + clanId: Int, + memberIndex: Int, + ) : this( + name, + clanId.toUByte(), + memberIndex.toUShort(), + ) + + public val clanId: Int + get() = _clanId.toInt() + public val memberIndex: Int + get() = _memberIndex.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AffinedClanSettingsAddBannedFromChannel + + if (name != other.name) return false + if (_clanId != other._clanId) return false + if (_memberIndex != other._memberIndex) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _clanId.hashCode() + result = 31 * result + _memberIndex.hashCode() + return result + } + + override fun toString(): String = + "AffinedClanSettingsAddBannedFromChannel(" + + "name='$name', " + + "clanId=$clanId, " + + "memberIndex=$memberIndex" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsSetMutedFromChannel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsSetMutedFromChannel.kt new file mode 100644 index 000000000..eee57da14 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/AffinedClanSettingsSetMutedFromChannel.kt @@ -0,0 +1,74 @@ +package net.rsprot.protocol.game.incoming.clan + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Clan ban messages are sent when a player with sufficient rank + * in the clan requests to mute another member within the clan. + * @property name the name of the player to mute + * @property clanId the id of the clan, ranging from 0 to 3 (inclusive). + * Negative values are not supported for mutes - it is not possible to + * mute others while you are in a clan as a guest. + * @property memberIndex the index of the member in the clan who's being muted. + * Note that the index isn't the player's absolute index in the world, but rather + * the index within this clan. + * @property muted whether to mute or unmute this player + */ +@Suppress("MemberVisibilityCanBePrivate") +public class AffinedClanSettingsSetMutedFromChannel private constructor( + public val name: String, + private val _clanId: UByte, + private val _memberIndex: UShort, + public val muted: Boolean, +) : IncomingGameMessage { + public constructor( + name: String, + clanId: Int, + memberIndex: Int, + muted: Boolean, + ) : this( + name, + clanId.toUByte(), + memberIndex.toUShort(), + muted, + ) + + public val clanId: Int + get() = _clanId.toInt() + public val memberIndex: Int + get() = _memberIndex.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AffinedClanSettingsSetMutedFromChannel + + if (name != other.name) return false + if (_clanId != other._clanId) return false + if (_memberIndex != other._memberIndex) return false + if (muted != other.muted) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _clanId.hashCode() + result = 31 * result + _memberIndex.hashCode() + result = 31 * result + muted.hashCode() + return result + } + + override fun toString(): String = + "AffinedClanSettingsSetMutedFromChannel(" + + "name='$name', " + + "clanId=$clanId, " + + "memberIndex=$memberIndex, " + + "muted=$muted" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelFullRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelFullRequest.kt new file mode 100644 index 000000000..af6bfc09c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelFullRequest.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.game.incoming.clan + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Clan channel requests are made whenever the server sends a clanchannel + * delta update, but the client does not have a clan defined at that id. + * In order to fix the problem, the client will then request for a full + * clan update for that clan id. + * @property clanId the id of the clan to request, ranging from 0 to 3 (inclusive), + * or a negative value if the request is for a guest-clan + */ +public class ClanChannelFullRequest( + public val clanId: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanChannelFullRequest + + return clanId == other.clanId + } + + override fun hashCode(): Int = clanId + + override fun toString(): String = "ClanChannelFullRequest(clanId=$clanId)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelKickUser.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelKickUser.kt new file mode 100644 index 000000000..401d98fe8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanChannelKickUser.kt @@ -0,0 +1,66 @@ +package net.rsprot.protocol.game.incoming.clan + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Clan kick messages are sent when a player with sufficient privileges + * requests to kick another player within the clan out of it. + * @property name the name of the player to kick + * @property clanId the id of the clan the player is in, ranging from 0 to 3 (inclusive), + * or negative values if referring to a guest clan + * @property memberIndex the index of the member in the clan who's being kicked. + * Note that the index isn't the player's absolute index in the world, but rather + * the index within this clan. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class ClanChannelKickUser private constructor( + public val name: String, + private val _clanId: Byte, + private val _memberIndex: UShort, +) : IncomingGameMessage { + public constructor( + name: String, + clanId: Int, + memberIndex: Int, + ) : this( + name, + clanId.toByte(), + memberIndex.toUShort(), + ) + + public val clanId: Int + get() = _clanId.toInt() + public val memberIndex: Int + get() = _memberIndex.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanChannelKickUser + + if (name != other.name) return false + if (_clanId != other._clanId) return false + if (_memberIndex != other._memberIndex) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _clanId + result = 31 * result + _memberIndex.hashCode() + return result + } + + override fun toString(): String = + "ClanChannelKickUser(" + + "name='$name', " + + "clanId=$clanId, " + + "memberIndex=$memberIndex" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanSettingsFullRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanSettingsFullRequest.kt new file mode 100644 index 000000000..2966ab1e9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/clan/ClanSettingsFullRequest.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.game.incoming.clan + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Clan settings requests are made whenever the server sends a clansettings + * delta update, but the update counter in the clan settings message + * is greater than that of the clan itself. In order to avoid problems, + * the client requests for a full clan settings update from the server, + * to re-synchronize all the values. + * @property clanId the id of the clan to request, ranging from 0 to 3 (inclusive), + * or a negative value if the request is for a guest-clan + */ +public class ClanSettingsFullRequest( + public val clanId: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanSettingsFullRequest + + return clanId == other.clanId + } + + override fun hashCode(): Int = clanId + + override fun toString(): String = "ClanSettingsFullRequest(clanId=$clanId)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventAppletFocus.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventAppletFocus.kt new file mode 100644 index 000000000..a94fccb59 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventAppletFocus.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Applet focus events are sent whenever the client either loses or gains focus. + * This can be seen by minimizing and maximizing the clients. + * @property inFocus whether the client was put into focus or out of focus + */ +public class EventAppletFocus( + public val inFocus: Boolean, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventAppletFocus + + return inFocus == other.inFocus + } + + override fun hashCode(): Int = inFocus.hashCode() + + override fun toString(): String = "EventAppletFocus(inFocus=$inFocus)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventCameraPosition.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventCameraPosition.kt new file mode 100644 index 000000000..19fff0bd6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventCameraPosition.kt @@ -0,0 +1,56 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Camera position events are sent whenever the client's camera changes position, + * at a maximum frequency of 20 client cycles (20ms/cc). + * @property angleX the x angle of the camera, in range of 128 to 383 (inclusive) + * @property angleY the y angle of the camera, in range of 0 to 2047 (inclusive) + */ +@Suppress("MemberVisibilityCanBePrivate") +public class EventCameraPosition private constructor( + private val _angleX: UShort, + private val _angleY: UShort, +) : IncomingGameMessage { + public constructor( + angleX: Int, + angleY: Int, + ) : this( + angleX.toUShort(), + angleY.toUShort(), + ) + + public val angleX: Int + get() = _angleX.toInt() + public val angleY: Int + get() = _angleY.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventCameraPosition + + if (_angleX != other._angleX) return false + if (_angleY != other._angleY) return false + + return true + } + + override fun hashCode(): Int { + var result = _angleX.hashCode() + result = 31 * result + _angleY.hashCode() + return result + } + + override fun toString(): String = + "EventCameraPosition(" + + "angleX=$angleX, " + + "angleY=$angleY" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventKeyboard.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventKeyboard.kt new file mode 100644 index 000000000..228073c1b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventKeyboard.kt @@ -0,0 +1,328 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import java.awt.event.KeyEvent + +/** + * Keyboard events are transmitted at a maximum frequency of every 20 milliseconds. + * This means that - almost always - a single key is only sent in each packet, + * as it is very unlikely to get more than one key pressed within a 20-millisecond + * window, even when trying. + * While the packet does send the [lastTransmittedKeyPress] per key pressed, + * there is a flaw in the logic and any subsequent keys after the first will + * always write a value of 0. For this reason, in order to reduce the memory + * footprint of this message, we omit any subsequent timestamps and reduce + * our keys to a byte array value class for even further compression. + * If the time delta is greater than 16,777,215 milliseconds since the last + * key transmission, the [lastTransmittedKeyPress] value will be 16,777,215. + */ +public class EventKeyboard( + public val lastTransmittedKeyPress: Int, + public val keysPressed: KeySequence, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventKeyboard + + if (lastTransmittedKeyPress != other.lastTransmittedKeyPress) return false + if (keysPressed != other.keysPressed) return false + + return true + } + + override fun hashCode(): Int { + var result = lastTransmittedKeyPress + result = 31 * result + keysPressed.hashCode() + return result + } + + override fun toString(): String = + "EventKeyboard(" + + "lastTransmittedKeyPress=$lastTransmittedKeyPress, " + + "keysPressed=$keysPressed" + + ")" + + /** + * KeySequence class represents a sequence of keys pressed in a byte array. + * This class provides helpful functionality to convert keys from the Jagex + * format back into the normalized [java.awt.event.KeyEvent] format. + * @property length the length of the key sequence + */ + public class KeySequence( + private val array: ByteArray, + ) { + public val length: Int + get() = array.size + + /** + * Returns the backing byte array of this key sequence, in Jagex format. + * It is worth noting that changes done to this array will directly + * modify this key sequence. + * All valid keys will be positive byte values. + */ + public fun asByteArray(): ByteArray = array + + /** + * Copies this backing key array into an int array, normalizing the + * values in the process - all keys will be either positive integers, + * or -1. + */ + public fun toIntArray(): IntArray = + IntArray(length) { index -> + getJagexKey(index) + } + + /** + * Transforms the backing key array into an int array with + * [java.awt.event.KeyEvent] key codes instead of the compressed + * Jagex format. Any invalid key will be represented as -1. + */ + public fun toAwtKeyCodeIntArray(): IntArray = + IntArray(length) { index -> + getAwtKey(index) + } + + /** + * Gets the Jagex key code at the provided [index]. + * @param index the index of the key code to obtain. + * @return Jagex compressed key code, or -1 if the key isn't valid. + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= [length]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getJagexKey(index: Int): Int { + val code = array[index].toInt() and 0xFF + return if (code == 0xFF) { + -1 + } else { + code + } + } + + /** + * Gets the [java.awt.event.KeyEvent] key code at the provided [index]. + * @param index the index of the key code to obtain. + * @return [java.awt.event.KeyEvent] key code, or -1 if the key isn't valid. + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= [length]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getAwtKey(index: Int): Int { + val jagexKey = getJagexKey(index) + return if (jagexKey == -1) { + -1 + } else { + jagexToAwtKeyCodes[jagexKey] + } + } + + /** + * Gets the [java.awt.event.KeyEvent] key code text at the provided [index]. + * @param index the index of the key code text to obtain. + * @return [java.awt.event.KeyEvent] key code text, or null if the key isn't valid. + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= [length]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getAwtKeyText(index: Int): String? { + val keyCode = getAwtKey(index) + return if (keyCode == -1) { + null + } else { + KeyEvent.getKeyText(keyCode) + } + } + + private companion object { + /** + * The key code translation array found in the client. + * The trailing -1s have been omitted in this array to shorten the data structure. + */ + private val awtToJagexKeyCodes = + intArrayOf( + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 85, + 80, + 84, + -1, + 91, + -1, + -1, + -1, + 81, + 82, + 86, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 13, + -1, + -1, + -1, + -1, + 83, + 104, + 105, + 103, + 102, + 96, + 98, + 97, + 99, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 25, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 48, + 68, + 66, + 50, + 34, + 51, + 52, + 53, + 39, + 54, + 55, + 56, + 70, + 69, + 40, + 41, + 32, + 35, + 49, + 36, + 38, + 67, + 33, + 65, + 37, + 64, + -1, + -1, + -1, + -1, + -1, + 228, + 231, + 227, + 233, + 224, + 219, + 225, + 230, + 226, + 232, + 89, + 87, + -1, + 88, + 229, + 90, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + -1, + -1, + -1, + 101, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 100, + -1, + 87, + ) + + /** + * The Jagex key codes to AWT key codes translation array. + */ + private val jagexToAwtKeyCodes = buildJagexToAwtKeyCodesArray() + + /** + * Builds a Jagex keycode to AWT key code translation array, + * used to normalize the keycode events into traditional values. + */ + private fun buildJagexToAwtKeyCodesArray(): IntArray { + val keys = IntArray(256) { -1 } + for ((index, keycode) in awtToJagexKeyCodes.withIndex()) { + if (keycode == -1) { + continue + } + keys[keycode] = index + } + return keys + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV1.kt new file mode 100644 index 000000000..c7f6228cd --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV1.kt @@ -0,0 +1,77 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Mouse click messages are sent whenever the user clicks with the + * right or left mouse button, and if the "Middle mouse button controls camera" + * is disabled, middle buttons (the scroll wheel itself). + * @property lastTransmittedMouseClick how many milliseconds since the last mouse + * click event was transmitted + * @property rightClick whether a right mouse click was performed, or left/middle. + * There is no distinction between left and middle transmitted to the server. + * @property x the x coordinate clicked, always a positive integer, capped to the + * client frame width. + * @property y the y coordinate clicked, always a positive integer, capped to the + * client frame height. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class EventMouseClickV1 private constructor( + private val _lastTransmittedMouseClick: UShort, + public val rightClick: Boolean, + private val _x: UShort, + private val _y: UShort, +) : IncomingGameMessage { + public constructor( + lastTransmittedMouseClick: Int, + rightClick: Boolean, + x: Int, + y: Int, + ) : this( + lastTransmittedMouseClick.toUShort(), + rightClick, + x.toUShort(), + y.toUShort(), + ) + + public val lastTransmittedMouseClick: Int + get() = _lastTransmittedMouseClick.toInt() + public val x: Int + get() = _x.toInt() + public val y: Int + get() = _y.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventMouseClickV1 + + if (_lastTransmittedMouseClick != other._lastTransmittedMouseClick) return false + if (rightClick != other.rightClick) return false + if (_x != other._x) return false + if (_y != other._y) return false + + return true + } + + override fun hashCode(): Int { + var result = _lastTransmittedMouseClick.hashCode() + result = 31 * result + rightClick.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _y.hashCode() + return result + } + + override fun toString(): String = + "EventMouseClickV1(" + + "lastTransmittedMouseClick=$lastTransmittedMouseClick, " + + "rightClick=$rightClick, " + + "x=$x, " + + "y=$y" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV2.kt new file mode 100644 index 000000000..bbcbf13b2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseClickV2.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Mouse click messages are sent whenever the user clicks with the + * right or left mouse button, and if the "Middle mouse button controls camera" + * is disabled, middle buttons (the scroll wheel itself). + * @property lastTransmittedMouseClick how many milliseconds since the last mouse + * click event was transmitted + * @property code the hook code from windows. This value is always 0 when used in Java. + * See [link here](https://learn.microsoft.com/en-us/windows/win32/winmsg/about-hooks?redirectedfrom=MSDN). + * @property rightClick whether a right mouse click was performed, or left/middle. + * There is no distinction between left and middle transmitted to the server. + * @property x the x coordinate clicked, always a positive integer, capped to the + * client frame width. + * @property y the y coordinate clicked, always a positive integer, capped to the + * client frame height. + */ +public class EventMouseClickV2 private constructor( + private val _lastTransmittedMouseClick: UShort, + private val _code: UByte, + public val rightClick: Boolean, + private val _x: UShort, + private val _y: UShort, +) : IncomingGameMessage { + public constructor( + lastTransmittedMouseClick: Int, + code: Int, + rightClick: Boolean, + x: Int, + y: Int, + ) : this( + lastTransmittedMouseClick.toUShort(), + code.toUByte(), + rightClick, + x.toUShort(), + y.toUShort(), + ) + + public val lastTransmittedMouseClick: Int + get() = _lastTransmittedMouseClick.toInt() + public val code: Int + get() = _code.toInt() + public val x: Int + get() = _x.toInt() + public val y: Int + get() = _y.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventMouseClickV2 + + if (_lastTransmittedMouseClick != other._lastTransmittedMouseClick) return false + if (_code != other._code) return false + if (rightClick != other.rightClick) return false + if (_x != other._x) return false + if (_y != other._y) return false + + return true + } + + override fun hashCode(): Int { + var result = _lastTransmittedMouseClick.hashCode() + result = 31 * result + _code.hashCode() + result = 31 * result + rightClick.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _y.hashCode() + return result + } + + override fun toString(): String = + "EventNativeMouseClickV2(" + + "lastTransmittedMouseClick=$lastTransmittedMouseClick, " + + "code=$code, " + + "rightClick=$rightClick" + + "x=$x, " + + "y=$y" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseMove.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseMove.kt new file mode 100644 index 000000000..6b2cf3e26 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseMove.kt @@ -0,0 +1,50 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.game.incoming.events.util.MouseMovements +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Mouse move messages are sent when the user moves their mouse across + * the client. + * @property stepExcess the extra milliseconds leftover after each mouse movement + * recording. + * @property endExcess the extra milliseconds leftover at the end of the packet's tracking. + * @property movements all the recorded mouse movements within this message. + * Mouse movements are recorded by the client at a 50 millisecond interval, + * meaning any movements within that 50 milliseconds are discarded, and + * only the position changes of the mouse at each 50 millisecond interval + * are sent. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class EventMouseMove private constructor( + private val _stepExcess: UByte, + private val _endExcess: UByte, + public val movements: MouseMovements, +) : IncomingGameMessage { + public constructor( + stepExcess: Int, + endExcess: Int, + movements: MouseMovements, + ) : this( + stepExcess.toUByte(), + endExcess.toUByte(), + movements, + ) + + public val stepExcess: Int + get() = _stepExcess.toInt() + + public val endExcess: Int + get() = _endExcess.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun toString(): String = + "EventMouseMove(" + + "movements=$movements, " + + "stepExcess=$stepExcess, " + + "endExcess=$endExcess" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseScroll.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseScroll.kt new file mode 100644 index 000000000..e44280c41 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventMouseScroll.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Mouse scroll message is sent whenever the user scrolls using their mouse. + * @property mouseWheelRotation the number of "clicks" the mouse wheel has rotated. + * If the mouse wheel was rotated up/away from the user, negative value is sent, + * and if the wheel was rotated down/towards the user, a positive value is sent. + */ +public class EventMouseScroll( + public val mouseWheelRotation: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EventMouseScroll + + return mouseWheelRotation == other.mouseWheelRotation + } + + override fun hashCode(): Int = mouseWheelRotation + + override fun toString(): String = "EventMouseScroll(mouseWheelRotation=$mouseWheelRotation)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventNativeMouseMove.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventNativeMouseMove.kt new file mode 100644 index 000000000..b77cb2d33 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/EventNativeMouseMove.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.game.incoming.events + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.game.incoming.events.util.MouseMovements +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Mouse move messages are sent when the user moves their mouse across + * the client, in this case, on the enhanced C++ clients. + * @property totalTime the total time in milliseconds that all the movements + * inside this event span across + * @property averageTime the average time in milliseconds between each movement. + * The average time is truncated according to integer division rules in the JVM. + * This is equal to `totalTime / count`. + * @property remainingTime the remaining time from the [averageTime] integer + * division. This is equal to `totalTime % count`. + * @property movements all the recorded mouse movements within this message. + * Mouse movements are recorded by the client at a 50 millisecond interval, + * meaning any movements within that 50 milliseconds are discarded, and + * only the position changes of the mouse at each 50 millisecond interval + * are sent. + */ +public class EventNativeMouseMove private constructor( + private val _averageTime: UByte, + private val _remainingTime: UByte, + public val movements: MouseMovements, +) : IncomingGameMessage { + public constructor( + averageTime: Int, + remainingTime: Int, + movements: MouseMovements, + ) : this( + averageTime.toUByte(), + remainingTime.toUByte(), + movements, + ) + + public val totalTime: Int + get() = (_averageTime.toInt() * movements.length) + _remainingTime.toInt() + + public val averageTime: Int + get() = _averageTime.toInt() + + public val remainingTime: Int + get() = _remainingTime.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun toString(): String = + "EventNativeMouseMove(" + + "movements=$movements, " + + "totalTime=$totalTime, " + + "averageTime=$averageTime, " + + "remainingTime=$remainingTime" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseMovements.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseMovements.kt new file mode 100644 index 000000000..39ca75cd5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseMovements.kt @@ -0,0 +1,140 @@ +package net.rsprot.protocol.game.incoming.events.util + +/** + * A class that wraps around an array of mouse movements, + * with the encoding specified by [MousePosChange]. + * @property length the number of mouse movements in this packet. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class MouseMovements( + private val movements: LongArray, +) { + public val length: Int + get() = movements.size + + /** + * @return the mouse movements data structure as a long array, + * with encoding as specified by [MousePosChange]. + * It is worth noting the encoding does not match up with the client. + * However, if people wish to store the mouse movements for later usage, + * this function provides the backing array which can be reconstructed + * at a later date. + * Changes to the backing array will directly reflect on this class. + */ + public fun asLongArray(): LongArray = movements + + /** + * Gets the mouse position change at the specified [index] + * @param index the index at which to obtain the mouse pos change. + * @return the mouse position change that occurred at that index. + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= [length] + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getMousePosChange(index: Int): MousePosChange = MousePosChange(movements[index]) + + /** + * A class for mouse position changes, packed into a primitive long. + * We utilize bitpacking in order to use primitive long arrays for space + * constraints. + * @property packed the bitpacked long value, exposed as servers may wish + * to re-compose the position changes at a later date. + * @property timeDelta the time difference in client cycles (20ms each) since the last + * transmitted mouse movement. + * @property x the x coordinate of the mouse, in pixels. If the + * mouse goes outside the client window, the value will be -1. + * @property y the y coordinate of the mouse, in pixels. If the + * mouse goes outside the client window, the value will be -1. + * @property lastMouseButton the last mouse button that was clicked shortly + * before the mouse movement. Value 0 means no recent click, 2 means left + * mouse click, 8 means right mouse click and 14 means middle mouse click. + * Other buttons are unknown but may also be possible. + * The value is 0x7FFF if no mouse button property is included, which is + * the case for the java variant of this packet. + */ + @Suppress("MemberVisibilityCanBePrivate") + public class MousePosChange( + public val packed: Long, + ) { + public constructor( + timeDelta: Int, + x: Int, + y: Int, + delta: Boolean, + ) : this( + timeDelta, + x, + y, + delta, + -1, + ) + + public constructor( + timeDelta: Int, + x: Int, + y: Int, + delta: Boolean, + lastMouseButton: Int, + ) : this( + pack(timeDelta, x, y, delta, lastMouseButton), + ) + + public val timeDelta: Int + get() = (packed and 0xFFFF).toInt() + public val x: Int + get() = (packed ushr 16 and 0xFFFF).toShort().toInt() + public val y: Int + get() = (packed ushr 32 and 0xFFFF).toShort().toInt() + public val delta: Boolean + get() = (packed ushr 48 and 0x1).toInt() != 0 + public val lastMouseButton: Int + get() = (packed ushr 49 and 0x7FFF).toInt() + + override fun toString(): String { + return if (delta) { + "MousePosChange(" + + "timeDelta=$timeDelta, " + + "deltaX=$x, " + + "deltaY=$y" + + (if (lastMouseButton != 0x7FFF) ", lastMouseButton=$lastMouseButton" else "") + + ")" + } else { + "MousePosChange(" + + "timeDelta=$timeDelta, " + + "x=$x, " + + "y=$y" + + (if (lastMouseButton != 0x7FFF) ", lastMouseButton=$lastMouseButton" else "") + + ")" + } + } + + public companion object { + public fun pack( + timeDelta: Int, + x: Int, + y: Int, + delta: Boolean, + ): Long = + pack( + timeDelta, + x, + y, + delta, + -1, + ) + + public fun pack( + timeDelta: Int, + x: Int, + y: Int, + delta: Boolean, + lastMouseButton: Int, + ): Long = + (timeDelta and 0xFFFF) + .toLong() + .or(x.toLong() and 0xFFFF shl 16) + .or(y.toLong() and 0xFFFF shl 32) + .or(if (delta) (1L shl 48) else 0) + .or(lastMouseButton.toLong() and 0x7FFF shl 49) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseTracker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseTracker.kt new file mode 100644 index 000000000..526e6361b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/events/util/MouseTracker.kt @@ -0,0 +1,160 @@ +package net.rsprot.protocol.game.incoming.events.util + +/** + * Tracks the mouse coordinate over time, applying the deltas/absolute positions as expected. + * When the time variable is set to zero, the delta exceeded maximum possible time delta. + * In order to fix an overflow issue within the Java clients, the timings are additionally reset + * to zero after 145,000 milliseconds have elapsed since the last tracking was performed. + * This is because the client will overflow the time after 163,820 milliseconds; we provide it + * just enough time to deal with any potential lag and buffering, ensuring the timings can be trusted. + */ +public class MouseTracker { + private var time: Int = 0 + private var x: Int = -1 + private var y: Int = -1 + private var lastTracking: Long = -1L + + /** + * Captures the mouse movements as submitted by the client and returns absolute values in a list. + * @param stepExcess the number of sub-client-cycle milliseconds each movement should have added to it. + * @param endExcess the number of sub-client-cycle milliseconds that should be added at the very end + * to keep the tracking precise. + * @param movements the list of mouse coordinates and time deltas for each recorded movement. + */ + public fun capture( + stepExcess: Int, + endExcess: Int, + movements: MouseMovements, + ): List { + val recordings = ArrayList(movements.length) + track(stepExcess, endExcess, movements) { x, y, time, lastMouseButton -> + recordings += Recording(x, y, time, lastMouseButton) + } + return recordings + } + + /** + * Tracks the mouse movements as submitted by the client without returning them to the caller. + * @param stepExcess the number of sub-client-cycle milliseconds each movement should have added to it. + * @param endExcess the number of sub-client-cycle milliseconds that should be added at the very end + * to keep the tracking precise. + * @param movements the list of mouse coordinates and time deltas for each recorded movement. + */ + public fun track( + stepExcess: Int, + endExcess: Int, + movements: MouseMovements, + ) { + track(stepExcess, endExcess, movements) { _, _, _, _ -> + // No-op, do nothing - just for keeping the values in sync + } + } + + /** + * Captures the mouse movements as submitted by the client and yields them via the [consumer]. + * @param stepExcess the number of sub-client-cycle milliseconds each movement should have added to it. + * @param endExcess the number of sub-client-cycle milliseconds that should be added at the very end + * to keep the tracking precise. + * @param movements the list of mouse coordinates and time deltas for each recorded movement. + * @param consumer the consumer for each mouse recording. + */ + private inline fun track( + stepExcess: Int, + endExcess: Int, + movements: MouseMovements, + consumer: (x: Int, y: Int, time: Int, mouseButton: Int) -> Unit, + ) { + for (i in 0..145 seconds has elapsed since last mouse packet). + */ + private fun untrustworthyTimings(): Boolean { + val elapsed = elapsedMillis() + return elapsed > TRUSTWORTHY_PACKET_DELAY + } + + /** + * Gets the number of milliseconds that have elapsed since the last mouse movement packet. + * @return number of milliseconds elapsed, or 0 if this is the first invocation/packet. + */ + private fun elapsedMillis(): Long { + val last = lastTracking + val time = System.currentTimeMillis() + lastTracking = time + return if (last == -1L) { + 0L + } else { + time - last + } + } + + /** + * Mouse movement recording data class. + * @property x the absolute x coordinate where the mouse was at the time of capturing. + * @property y the absolute y coordinate where the mouse was at the time of capturing. + * @property time the number of milliseconds that have elapsed since the last recording. + * This value will be 0 the time delta is too high (> 145 seconds). + * @property lastMouseButton the last mouse button that was clicked. This is only assigned on native, + * and remains at 0x7FFF on other clients. + */ + public data class Recording( + public val x: Int, + public val y: Int, + public val time: Int, + public val lastMouseButton: Int = 0x7FFF, + ) + + private companion object { + /** + * The number of milliseconds per client cycle, used in encoding the packet. + */ + private const val MILLISECONDS_PER_CLIENT_CYCLE: Int = 20 + + /** + * The maximum number of milliseconds that can elapse between two mouse recordings in client + * before the value overflows when writing to the server. + */ + private const val OVERFLOW_TIME_LIMIT: Long = 8191L * MILLISECONDS_PER_CLIENT_CYCLE + + /** + * The number of milliseconds that can elapse at most before the game connection is killed off. + */ + private const val GAME_TIMEOUT: Long = 15 * 1000L + + /** + * Extra buffer for packet processing, since it is synced to server cycles. + * Furthermore, the value is rounded off so the limit is a nice round 145 seconds. + */ + private const val PROCESSING_BUFFER_TIME: Long = 3820L + + /** + * The maximum number of millisecond that can elapse before the first timing in a mouse movement packet + * is considered untrustworthy. + */ + private const val TRUSTWORTHY_PACKET_DELAY: Long = OVERFLOW_TIME_LIMIT - GAME_TIMEOUT - PROCESSING_BUFFER_TIME + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatJoinLeave.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatJoinLeave.kt new file mode 100644 index 000000000..dbdc3c7b7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatJoinLeave.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.friendchat + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Friend chat join-leave message is sent when the player joins or leaves + * a friend chat channel. + * @property name the name of the player whose friend chat channel to join, + * or null if the player is leaving a friend chat channel + */ +public class FriendChatJoinLeave( + public val name: String?, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendChatJoinLeave + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "FriendChatJoinLeave(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatKick.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatKick.kt new file mode 100644 index 000000000..848b8c839 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatKick.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.friendchat + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Friend chat kick is sent when the owner requests to click another + * player from their friend chat. + * @property name the name of the player to kick + */ +public class FriendChatKick( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendChatKick + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "FriendChatKick(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatSetRank.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatSetRank.kt new file mode 100644 index 000000000..efc9c3cce --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/friendchat/FriendChatSetRank.kt @@ -0,0 +1,54 @@ +package net.rsprot.protocol.game.incoming.friendchat + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Friend chat set rank message is sent when the owner of a friend chat + * channel changes the rank of another player who is on their friendlist. + * @property name the name of the player whose rank to change + * @property rank the id of the new rank to set to that player + */ +@Suppress("MemberVisibilityCanBePrivate") +public class FriendChatSetRank private constructor( + public val name: String, + private val _rank: UByte, +) : IncomingGameMessage { + public constructor( + name: String, + rank: Int, + ) : this( + name, + rank.toUByte(), + ) + + public val rank: Int + get() = _rank.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendChatSetRank + + if (name != other.name) return false + if (_rank != other._rank) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _rank.hashCode() + return result + } + + override fun toString(): String = + "FriendChatSetRank(" + + "name='$name', " + + "rank=$rank" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc.kt new file mode 100644 index 000000000..a1ad3385d --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.game.incoming.locs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpLoc messages are fired when a player clicks one of the five (excluding oploc6) + * options on a loc in the game. + * @property id the base(non-multi-transformed) id of the loc the player clicked on + * @property x the absolute x coordinate of the south-western corner of the loc + * @property z the absolute z coordinate of the south-western corner of the loc + * @property controlKey whether the control key was held down, used to invert movement speed + * @property op the option clicked, ranging from 1 to 5 (inclusive) + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpLoc private constructor( + private val _id: UShort, + private val _x: UShort, + private val _z: UShort, + public val controlKey: Boolean, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + id: Int, + x: Int, + z: Int, + controlKey: Boolean, + op: Int, + ) : this( + id.toUShort(), + x.toUShort(), + z.toUShort(), + controlKey, + op.toUByte(), + ) + + public val id: Int + get() = _id.toInt() + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpLoc + + if (_id != other._id) return false + if (_x != other._x) return false + if (_z != other._z) return false + if (controlKey != other.controlKey) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "OpLoc(" + + "id=$id, " + + "x=$x, " + + "z=$z, " + + "controlKey=$controlKey, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc6.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc6.kt new file mode 100644 index 000000000..c039a3284 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLoc6.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.locs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpLoc6 message is fired whenever a player clicks examine on a loc. + * @property id the id of the loc (if multiloc, transformed to the + * currently visible variant) + */ +public class OpLoc6( + public val id: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpLoc6 + + return id == other.id + } + + override fun hashCode(): Int = id + + override fun toString(): String = "OpLoc6(id=$id)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLocT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLocT.kt new file mode 100644 index 000000000..c6e256e35 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/locs/OpLocT.kt @@ -0,0 +1,105 @@ +package net.rsprot.protocol.game.incoming.locs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * OpLocT messages are fired whenever an interface component is targeted + * on a loc, which, as of revision 204, includes using items from + * the player's inventory on locs - the OpLocU message was deprecated. + * @property id the base(non-multi-transformed) id of the loc the component was used on + * @property x the absolute x coordinate of the south-western corner of the loc + * @property z the absolute z coordinate of the south-western corner of the loc + * @property controlKey whether the control key was held down, used to invert movement speed + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id of the selected component + * @property selectedComponentId the component id being used on the loc + * @property selectedSub the subcomponent of the selected component, or -1 of none exists + * @property selectedObj the obj on the selected subcomponent, or -1 if none exists + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpLocT private constructor( + private val _id: UShort, + private val _x: UShort, + private val _z: UShort, + public val controlKey: Boolean, + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, +) : IncomingGameMessage { + public constructor( + id: Int, + x: Int, + z: Int, + controlKey: Boolean, + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + ) : this( + id.toUShort(), + x.toUShort(), + z.toUShort(), + controlKey, + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + ) + + public val id: Int + get() = _id.toInt() + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpLocT + + if (_id != other._id) return false + if (controlKey != other.controlKey) return false + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + return result + } + + override fun toString(): String = + "OpLocT(" + + "id=$id, " + + "x=$x, " + + "z=$z, " + + "controlKey=$controlKey, " + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePrivate.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePrivate.kt new file mode 100644 index 000000000..c91196715 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePrivate.kt @@ -0,0 +1,44 @@ +package net.rsprot.protocol.game.incoming.messaging + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Message private events are sent when a player writes a private + * message to the target player. The server is responsible for looking + * up the target player and forwarding the message to them, if possible. + * @property name the name of the recipient of this private message + * @property message the message forwarded to the recipient + */ +public class MessagePrivate( + public val name: String, + public val message: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessagePrivate + + if (name != other.name) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessagePrivate(" + + "name='$name', " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePublic.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePublic.kt new file mode 100644 index 000000000..2ef17e276 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/messaging/MessagePublic.kt @@ -0,0 +1,249 @@ +package net.rsprot.protocol.game.incoming.messaging + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Message public events are sent when the player talks in public. + * + * Chat types table: + * ``` + * | Id | Type | + * |----|:------------------:| + * | 0 | Normal | + * | 1 | Autotyper | + * | 2 | Friend channel | + * | 3 | Clan main channel | + * | 4 | Clan guest channel | + * ``` + * + * Colour table: + * ``` + * | Id | Prefix | Hex Value | + * |-------|-----------|:--------------------------:| + * | 0 | yellow: | 0xFFFF00 | + * | 1 | red: | 0xFF0000 | + * | 2 | green: | 0x00FF00 | + * | 3 | cyan: | 0x00FFFF | + * | 4 | purple: | 0xFF00FF | + * | 5 | white: | 0xFFFFFF | + * | 6 | flash1: | 0xFF0000/0xFFFF00 | + * | 7 | flash2: | 0x0000FF/0x00FFFF | + * | 8 | flash3: | 0x00B000/0x80FF80 | + * | 9 | glow1: | 0xFF0000-0xFFFF00-0x00FFFF | + * | 10 | glow2: | 0xFF0000-0x00FF00-0x0000FF | + * | 11 | glow3: | 0xFFFFFF-0x00FF00-0x00FFFF | + * | 12 | rainbow: | N/A | + * | 13-20 | pattern*: | N/A | + * ``` + * + * Effects table: + * ``` + * | Id | Prefix | + * |----|---------| + * | 1 | wave: | + * | 2 | wave2: | + * | 3 | shake: | + * | 4 | scroll: | + * | 5 | slide: | + * ``` + * + * Clan types table: + * ``` + * | Id | Type | + * |----|:-------------:| + * | 0 | Normal clan | + * | 1 | Group ironman | + * | 2 | PvP Arena | + * ``` + * + * @property type the type of the message, ranging from 0 to 4 (inclusive) (see above) + * @property colour the colour of the message, ranging from 0 to 20 (inclusive) (see above) + * @property effect the effect of the message, ranging from 0 to 5 (inclusive) (see above) + * @property message the message typed + * @property pattern the colour pattern attached to the message, if the [colour] value is + * in range of 13-20 (inclusive), otherwise null + * @property clanType the clan type, if the [type] is the main clan channel, + * a value of 0 to 2 (inclusive) is provided. If the clan type is not defined, + * the value of -1 is given. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class MessagePublic private constructor( + private val _type: UByte, + private val _colour: UByte, + private val _effect: UByte, + public val message: String, + public val pattern: MessageColourPattern?, + private val _clanType: Byte, +) : IncomingGameMessage { + public constructor( + type: Int, + colour: Int, + effect: Int, + message: String, + pattern: MessageColourPattern?, + clanType: Int, + ) : this( + type.toUByte(), + colour.toUByte(), + effect.toUByte(), + message, + pattern, + clanType.toByte(), + ) + + public val type: Int + get() = _type.toInt() + public val colour: Int + get() = _colour.toInt() + public val effect: Int + get() = _effect.toInt() + public val clanType: Int + get() = _clanType.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessagePublic + + if (_type != other._type) return false + if (_colour != other._colour) return false + if (_effect != other._effect) return false + if (message != other.message) return false + if (pattern != other.pattern) return false + if (_clanType != other._clanType) return false + + return true + } + + override fun hashCode(): Int { + var result = _type.hashCode() + result = 31 * result + _colour.hashCode() + result = 31 * result + _effect.hashCode() + result = 31 * result + message.hashCode() + result = 31 * result + (pattern?.hashCode() ?: 0) + result = 31 * result + _clanType + return result + } + + override fun toString(): String = + "MessagePublicEvent(" + + "message='$message', " + + "pattern=$pattern, " + + "type=$type, " + + "colour=$colour, " + + "effect=$effect, " + + "clanType=$clanType" + + ")" + + /** + * A class for message colour patterns, allowing easy + * conversion from the byte array to the respective 24-bit RGB colours. + * This wrapper class additionally provides a helpful [isValid] function, + * as it is possible to otherwise send bad data from the client and + * crash the players in vicinity. + */ + public class MessageColourPattern( + private val bytes: ByteArray, + ) { + public val length: Int + get() = bytes.size + + /** + * @return the backing byte array for the pattern. + * Changes done to this array will reflect on the pattern itself. + */ + public fun asByteArray(): ByteArray = bytes + + /** + * @return a copy of the backing pattern byte array. + */ + public fun toByteArray(): ByteArray = bytes.copyOf() + + /** + * Checks if the pattern itself is valid (as in, will not crash the client). + * The client's own checks are currently slightly flawed and allow for + * crashes to occur in one particular manner. + * @return whether the pattern is valid + */ + public fun isValid(): Boolean { + if (length !in 1..8) { + return false + } + for (i in bytes.indices) { + val value = bytes[i].toInt() + if (value < 0 || value >= colourCodes.size) { + return false + } + } + return true + } + + /** + * Turns the pattern into a 24-bit RGB colour array, if it is valid. + * @return 24-bit RGB colour array of this pattern, or null if the pattern + * is corrupt. + */ + public fun to24BitRgbOrNull(): IntArray? { + if (length !in 1..8) { + return null + } + val colours = IntArray(length) + for (i in bytes.indices) { + val colourCode = + colourCodes.getOrNull(bytes[i].toInt()) + ?: return null + colours[i] = colourCode + } + return colours + } + + override fun toString(): String = "MessageColourPattern(bytes=${bytes.contentToString()})" + + private companion object { + private val colourCodes = + intArrayOf( + 0xffffff, + 0xe40303, + 0xff8c00, + 0xffed00, + 0x8026, + 0x24408e, + 0x732982, + 0xff218c, + 0xb55690, + 0x5049cc, + 0xa3a3a3, + 0xd52d00, + 0xef7627, + 0xfcf434, + 0x78d70, + 0x21b1ff, + 0x9b4f96, + 0xffafc7, + 0xd162a4, + 0x7bade3, + 0xff9a56, + 0x26ceaa, + 0x73d7ee, + 0x9c59d1, + 0x98e8c1, + 0xb57edc, + 0x2c2c2c, + 0x940202, + 0x613915, + 0xd0c100, + 0x4a8123, + 0x38a8, + 0x800080, + 0xd60270, + 0xa30262, + 0x3d1a78, + ) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ConnectionTelemetry.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ConnectionTelemetry.kt new file mode 100644 index 000000000..e3a001707 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ConnectionTelemetry.kt @@ -0,0 +1,79 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Connection telemetry is sent as part of the first packets during login, + * written during the part of login that handles login rebuild messages + * and player info initialization. + * While the packet sends more properties than the four listed here, + * they are never assigned a value, so they're just dummy zeros. + * @property connectionLostDuration how long the connection was lost for. + * Each unit here equals 10 milliseconds. The value is coerced in 0..65535 + * @property loginDuration how long the login took to complete. + * Each unit here equals 10 milliseconds. The value is coerced in 0..65535 + * @property clientState the state the client is in + * @property loginCount how many login attempts have occurred. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class ConnectionTelemetry private constructor( + private val _connectionLostDuration: UShort, + private val _loginDuration: UShort, + private val _clientState: UShort, + private val _loginCount: UShort, +) : IncomingGameMessage { + public constructor( + connectionLostDuration: Int, + loginDuration: Int, + clientState: Int, + loginCount: Int, + ) : this( + connectionLostDuration.toUShort(), + loginDuration.toUShort(), + clientState.toUShort(), + loginCount.toUShort(), + ) + + public val connectionLostDuration: Int + get() = _connectionLostDuration.toInt() + public val loginDuration: Int + get() = _loginDuration.toInt() + public val clientState: Int + get() = _clientState.toInt() + public val loginCount: Int + get() = _loginCount.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConnectionTelemetry + + if (_connectionLostDuration != other._connectionLostDuration) return false + if (_loginDuration != other._loginDuration) return false + if (_clientState != other._clientState) return false + if (_loginCount != other._loginCount) return false + + return true + } + + override fun hashCode(): Int { + var result = _connectionLostDuration.hashCode() + result = 31 * result + _loginDuration.hashCode() + result = 31 * result + _clientState.hashCode() + result = 31 * result + _loginCount.hashCode() + return result + } + + override fun toString(): String = + "ConnectionTelemetry(" + + "connectionLostDuration=$connectionLostDuration, " + + "loginDuration=$loginDuration, " + + "clientState=$clientState, " + + "loginCount=$loginCount" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/DetectModifiedClient.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/DetectModifiedClient.kt new file mode 100644 index 000000000..b4dbe0adb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/DetectModifiedClient.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Detect modified client is sent by the client right before a map load + * if the client has been given a frame. For simple deobs, this is generally + * not the case. + * In OSRS, the code is consistently sent as '1,057,001,181'. + */ +public class DetectModifiedClient( + public val code: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DetectModifiedClient + + return code == other.code + } + + override fun hashCode(): Int = code + + override fun toString(): String = "DetectModifiedClient(code=$code)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/Idle.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/Idle.kt new file mode 100644 index 000000000..bd940ac95 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/Idle.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Idle messages are sent if the user hasn't interacted with their + * mouse nor their keyboard for 15,000 client cycles (20ms/cc) in a row, + * meaning continuous inactivity for five minutes in a row. + */ +public data object Idle : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MapBuildComplete.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MapBuildComplete.kt new file mode 100644 index 000000000..51e03a4a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MapBuildComplete.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Map build complete is sent when the client finishes building map after + * a map reload. This packet is primarily used by the server for `p_loaddelay;` + * procs, which delay current active script until the client has finished loading + * the map, with a 10-game-cycle timeout. + */ +public data object MapBuildComplete : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MembershipPromotionEligibility.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MembershipPromotionEligibility.kt new file mode 100644 index 000000000..909244c65 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/MembershipPromotionEligibility.kt @@ -0,0 +1,60 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * An enhanced-client-only packet to inform the server of the status of + * membership eligibility. + * @property eligibleForIntroductoryPrice whether the player is eligible for + * an introductory price, kept in an integer form in case there are more values + * than just yes/no. + * @property eligibleForTrialPurchase whether the player is eligible + * for a trial purchase, kept int an integer form in case there are more values + * than just yes/no. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class MembershipPromotionEligibility private constructor( + private val _eligibleForIntroductoryPrice: UByte, + private val _eligibleForTrialPurchase: UByte, +) : IncomingGameMessage { + public constructor( + eligibleForIntroductoryPrice: Int, + eligibleForTrialPurchase: Int, + ) : this( + eligibleForIntroductoryPrice.toUByte(), + eligibleForTrialPurchase.toUByte(), + ) + + public val eligibleForIntroductoryPrice: Int + get() = _eligibleForIntroductoryPrice.toInt() + public val eligibleForTrialPurchase: Int + get() = _eligibleForTrialPurchase.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MembershipPromotionEligibility + + if (_eligibleForIntroductoryPrice != other._eligibleForIntroductoryPrice) return false + if (_eligibleForTrialPurchase != other._eligibleForTrialPurchase) return false + + return true + } + + override fun hashCode(): Int { + var result = _eligibleForIntroductoryPrice.hashCode() + result = 31 * result + _eligibleForTrialPurchase.hashCode() + return result + } + + override fun toString(): String = + "MembershipPromotionEligibility(" + + "eligibleForIntroductoryPrice=$eligibleForIntroductoryPrice, " + + "eligibleForTrialPurchase=$eligibleForTrialPurchase" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/NoTimeout.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/NoTimeout.kt new file mode 100644 index 000000000..41088c8c3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/NoTimeout.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * No timeout packets are sent every 50 client cycles (20ms/cc) + * to ensure the server doesn't disconnect the client due to inactivity. + */ +public data object NoTimeout : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/RSevenStatus.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/RSevenStatus.kt new file mode 100644 index 000000000..24b4e64fa --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/RSevenStatus.kt @@ -0,0 +1,45 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * RSeven Status packet is sent to inform the client of various RT7 related properties on login. + * This packet is only sent on the native client. + * @property packed the bitpacked flag containing RT7 properties. + * As of revision 231.2, the very first bit is for 'force disable rseven' and the second bit is + * always enabled. The other bits are currently not in use. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class RSevenStatus private constructor( + private val _packedValue: UByte, +) : IncomingGameMessage { + public constructor( + packedValue: Int, + ) : this( + packedValue.toUByte(), + ) + + public val packed: Int + get() = _packedValue.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RSevenStatus + + return _packedValue == other._packedValue + } + + override fun hashCode(): Int { + return _packedValue.hashCode() + } + + override fun toString(): String { + return "RSevenStatus(packed=$packed)" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ReflectionCheckReply.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ReflectionCheckReply.kt new file mode 100644 index 000000000..03d0fbb76 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/ReflectionCheckReply.kt @@ -0,0 +1,476 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.buffer.extensions.checkCRC32 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.game.outgoing.misc.client.ReflectionChecker +import net.rsprot.protocol.message.IncomingGameMessage +import java.io.IOException +import java.io.InvalidClassException +import java.io.OptionalDataException +import java.io.StreamCorruptedException +import java.lang.reflect.InvocationTargetException +import kotlin.IllegalArgumentException + +/** + * A reflection check reply is sent by the client whenever a server requests + * a reflection checker to be performed. + * @property id the original request id sent by the server. + * @property result the resulting byte buffer slice. + * As decoding reflection checks requires knowing the original request that was made, + * we have to defer the decoding of the payload until the original request is + * provided to us, thus, using [decode] we can obtain the real results. + */ +public class ReflectionCheckReply( + public val id: Int, + public val result: ByteBuf, +) : DefaultByteBufHolder(result), + IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.CLIENT_EVENT + + /** + * Decodes the reply using the original [request] that the server put in. + * It is worth noting that the [result] buffer will always be released + * after the decoding function call, so it may only be called once. + */ + public fun decode(request: ReflectionChecker): List> { + try { + val buffer = result.toJagByteBuf() + // Skip the id, it is necessary for CRC verification though. + buffer.skipRead(4) + val results = ArrayList>(request.checks.size) + for (check in request.checks) { + val opcode = buffer.g1s() + if (opcode < 0) { + if (opcode <= -10) { + val throwable = getExecutionThrowableClass(opcode) + results += + ErrorResult( + check, + ErrorResult.ThrowableResultType.ExecutionThrowable(throwable), + ) + } else { + val throwable = getConstructionThrowableClass(opcode) + results += + ErrorResult( + check, + ErrorResult.ThrowableResultType.ConstructionThrowable(throwable), + ) + } + continue + } + when (check) { + is ReflectionChecker.GetFieldValue -> { + val result = buffer.g4() + results += GetFieldValueResult(check, result) + } + + is ReflectionChecker.SetFieldValue -> { + results += SetFieldValueResult(check) + } + + is ReflectionChecker.GetFieldModifiers -> { + val modifiers = buffer.g4() + results += GetFieldModifiersResult(check, modifiers) + } + + is ReflectionChecker.InvokeMethod -> { + results += + when (opcode) { + 0 -> InvokeMethodResult(check, NullReturnValue) + 1 -> InvokeMethodResult(check, NumberReturnValue(buffer.g8())) + 2 -> InvokeMethodResult(check, StringReturnValue(buffer.gjstr())) + 4 -> InvokeMethodResult(check, UnknownReturnValue) + else -> throw IllegalStateException("Unknown opcode for method invocation: $opcode") + } + } + + is ReflectionChecker.GetMethodModifiers -> { + val modifiers = buffer.g4() + results += GetMethodModifiersResult(check, modifiers) + } + } + } + result.readerIndex(result.writerIndex()) + if (!result.checkCRC32()) { + throw IllegalStateException("CRC mismatch!") + } + return results + } finally { + result.release() + } + } + + /** + * Gets the throwable class corresponding to each opcode during the reflection check execution. + * @param opcode the opcode value + * @return the throwable class corresponding to that opcode + */ + private fun getExecutionThrowableClass(opcode: Int): Class = + when (opcode) { + -10 -> ClassNotFoundException::class.java + -11 -> InvalidClassException::class.java + -12 -> StreamCorruptedException::class.java + -13 -> OptionalDataException::class.java + -14 -> IllegalAccessException::class.java + -15 -> IllegalArgumentException::class.java + -16 -> InvocationTargetException::class.java + -17 -> SecurityException::class.java + -18 -> IOException::class.java + -19 -> NullPointerException::class.java + -20 -> Exception::class.java + -21 -> Throwable::class.java + else -> throw IllegalArgumentException("Unknown execution throwable opcode: $opcode") + } + + /** + * Gets the throwable class corresponding to each opcode during the reflection check construction. + * @param opcode the opcode value + * @return the throwable class corresponding to that opcode + */ + private fun getConstructionThrowableClass(opcode: Int): Class = + when (opcode) { + -1 -> ClassNotFoundException::class.java + -2 -> SecurityException::class.java + -3 -> NullPointerException::class.java + -4 -> Exception::class.java + -5 -> Throwable::class.java + else -> throw IllegalArgumentException("Unknown construction throwable opcode: $opcode") + } + + override fun toString(): String = + "ReflectionCheckReply(" + + "id=$id, " + + "result=$result" + + ")" + + public sealed interface ReflectionCheckResult { + public val check: T + } + + /** + * Any error result will be in its own class, as there will not be any + * return values included in this lot. + * @property check the reflection check requested by the server + * @property throwable the throwable class that the client received during either construction or execution. + */ + public class ErrorResult>( + override val check: T, + public val throwable: ThrowableResultType, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ErrorResult<*, *> + + if (check != other.check) return false + if (throwable != other.throwable) return false + + return true + } + + override fun hashCode(): Int { + var result = check.hashCode() + result = 31 * result + throwable.hashCode() + return result + } + + override fun toString(): String = + "ErrorResult(" + + "check=$check, " + + "throwable=$throwable" + + ")" + + /** + * The throwable result types notify the user whether the throwable was caught during the + * construction of the reflection check where it looks up each class/field, or during + * the execution, where it looks up or assigns new values to properties. As the exceptions + * overlap, we need to distinguish the two types with a different wrapper. + * @property throwableClass the class that was thrown. + */ + public sealed interface ThrowableResultType> { + public val throwableClass: E + + /** + * A construction throwable is a throwable that was caught during the construction + * of a reflection check, e.g. when looking up the classes or fields on which the operations + * would be performed. + */ + public class ConstructionThrowable>( + override val throwableClass: E, + ) : ThrowableResultType { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ConstructionThrowable<*>) return false + + if (throwableClass != other.throwableClass) return false + + return true + } + + override fun hashCode(): Int { + return throwableClass.hashCode() + } + + override fun toString(): String { + return "ConstructionThrowable(" + + "throwableClass=$throwableClass" + + ")" + } + } + + /** + * An execution throwable is a throwable that was caught during the execution of a specific + * operation that was requested, e.g. GetFieldModifiers or SetFieldValue. + */ + public class ExecutionThrowable>( + override val throwableClass: E, + ) : ThrowableResultType { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ExecutionThrowable<*>) return false + + if (throwableClass != other.throwableClass) return false + + return true + } + + override fun hashCode(): Int { + return throwableClass.hashCode() + } + + override fun toString(): String { + return "ExecutionThrowable(" + + "throwableClass=$throwableClass" + + ")" + } + } + } + } + + /** + * Get field value result provides a successful result for retrieving a + * value of a field in the client. + * @property check the reflection check requested by the server + * @property value the value that the client received after invoking reflection + */ + public class GetFieldValueResult( + override val check: ReflectionChecker.GetFieldValue, + public val value: Int, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetFieldValueResult + + if (check != other.check) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = check.hashCode() + result = 31 * result + value + return result + } + + override fun toString(): String = + "GetFieldValueResult(" + + "check=$check, " + + "value=$value" + + ")" + } + + /** + * Set field value results will only ever be successful if a value was + * successfully assigned, in which case nothing gets returned. + * @property check the reflection check requested by the server + */ + public class SetFieldValueResult( + override val check: ReflectionChecker.SetFieldValue, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetFieldValueResult + + return check == other.check + } + + override fun hashCode(): Int = check.hashCode() + + override fun toString(): String = "SetFieldValueResult(check=$check)" + } + + /** + * Get field modifiers result will attempt to look up the modifiers + * of a field. + * @property check the reflection check requested by the server + * @property modifiers the bitpacked modifier values as assigned by the JVM + */ + public class GetFieldModifiersResult( + override val check: ReflectionChecker.GetFieldModifiers, + public val modifiers: Int, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetFieldModifiersResult + + if (check != other.check) return false + if (modifiers != other.modifiers) return false + + return true + } + + override fun hashCode(): Int { + var result = check.hashCode() + result = 31 * result + modifiers + return result + } + + override fun toString(): String = + "GetFieldModifiersResult(" + + "check=$check, " + + "modifiers=$modifiers" + + ")" + } + + /** + * Invoke method result is sent when a method invocation was successfully + * performed with the provided arguments and return type. + * @property check the reflection check requested by the server + * @property result the result of invoking the method + */ + public class InvokeMethodResult( + override val check: ReflectionChecker.InvokeMethod, + public val result: T, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as InvokeMethodResult<*> + + if (check != other.check) return false + if (result != other.result) return false + + return true + } + + override fun hashCode(): Int { + var result1 = check.hashCode() + result1 = 31 * result1 + result.hashCode() + return result1 + } + + override fun toString(): String = + "InvokeMethodResult(" + + "check=$check, " + + "result=$result" + + ")" + } + + /** + * Get method modifiers will attempt to look up the modifiers of a method + * using reflection. + * @property check the reflection check requested by the server + * @property modifiers the bitpacked modifier values as assigned by the JVM + */ + public class GetMethodModifiersResult( + override val check: ReflectionChecker.GetMethodModifiers, + public val modifiers: Int, + ) : ReflectionCheckResult { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetMethodModifiersResult + + if (check != other.check) return false + if (modifiers != other.modifiers) return false + + return true + } + + override fun hashCode(): Int { + var result = check.hashCode() + result = 31 * result + modifiers + return result + } + + override fun toString(): String = + "GetMethodModifiersResult(" + + "check=$check, " + + "modifiers=$modifiers" + + ")" + } + + public sealed interface MethodInvocationReturnValue + + /** + * A null return value is sent if a method invocation returned a null value. + */ + public data object NullReturnValue : MethodInvocationReturnValue + + /** + * A number return value is sent if a method returns any [Number] type, + * in which case the client will call [java.lang.Number.longValue] + * to retrieve the long representation of the value. + * @property longValue the long representation of the numeric value. + */ + public class NumberReturnValue( + public val longValue: Long, + ) : MethodInvocationReturnValue { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NumberReturnValue + + return longValue == other.longValue + } + + override fun hashCode(): Int = longValue.hashCode() + + override fun toString(): String = "NumberReturnValue(longValue=$longValue)" + } + + /** + * A string return value is provided if a method invocation results + * in a string value. + * @property stringValue the string value returned by the method. + */ + public class StringReturnValue( + public val stringValue: String, + ) : MethodInvocationReturnValue { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringReturnValue + + return stringValue == other.stringValue + } + + override fun hashCode(): Int = stringValue.hashCode() + + override fun toString(): String = "StringReturnValue(stringValue='$stringValue')" + } + + /** + * An unknown return value is provided when a method returns a value, + * but that value is not a null, a number of a string - essentially + * the 'else' case if all else falls through. + */ + public data object UnknownReturnValue : MethodInvocationReturnValue +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SendPingReply.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SendPingReply.kt new file mode 100644 index 000000000..03e237bc5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SendPingReply.kt @@ -0,0 +1,70 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Sends a ping reply to the server whenever the server requests it. + * @property fps the current fps of the client at the time of the message + * @property gcPercentTime the approximate percentage of the time spent + * garbage collecting + * @property value1 the first integer value sent by the serer + * @property value2 the second integer value sent by the server + */ +@Suppress("MemberVisibilityCanBePrivate") +public class SendPingReply private constructor( + private val _fps: UByte, + private val _gcPercentTime: UByte, + public val value1: Int, + public val value2: Int, +) : IncomingGameMessage { + public constructor( + fps: Int, + gcPercentTime: Int, + value1: Int, + value2: Int, + ) : this( + fps.toUByte(), + gcPercentTime.toUByte(), + value1, + value2, + ) + + public val fps: Int + get() = _fps.toInt() + public val gcPercentTime: Int + get() = _gcPercentTime.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SendPingReply + + if (_fps != other._fps) return false + if (_gcPercentTime != other._gcPercentTime) return false + if (value1 != other.value1) return false + if (value2 != other.value2) return false + + return true + } + + override fun hashCode(): Int { + var result = _fps.hashCode() + result = 31 * result + _gcPercentTime.hashCode() + result = 31 * result + value1 + result = 31 * result + value2 + return result + } + + override fun toString(): String = + "SendPingReply(" + + "fps=$fps, " + + "gcPercentTime=$gcPercentTime, " + + "value1=$value1, " + + "value2=$value2" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SoundJingleEnd.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SoundJingleEnd.kt new file mode 100644 index 000000000..6bf404035 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/SoundJingleEnd.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Sound jingle end packet is sent when a jingle finishes playing in the client, + * used to resume normal music from the start again (basically informs the server + * that it needs to reset its internal play-time counter back to zero). + */ +public class SoundJingleEnd( + public val jingleId: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SoundJingleEnd + + return jingleId == other.jingleId + } + + override fun hashCode(): Int = jingleId + + override fun toString(): String = "SoundJingleEnd(jingleId=$jingleId)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/WindowStatus.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/WindowStatus.kt new file mode 100644 index 000000000..e86b7c08c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/client/WindowStatus.kt @@ -0,0 +1,62 @@ +package net.rsprot.protocol.game.incoming.misc.client + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Window status is sent first on login, and afterwards whenever + * the client changes window status. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class WindowStatus private constructor( + private val _windowMode: UByte, + private val _frameWidth: UShort, + private val _frameHeight: UShort, +) : IncomingGameMessage { + public constructor( + windowMode: Int, + frameWidth: Int, + frameHeight: Int, + ) : this( + windowMode.toUByte(), + frameWidth.toUShort(), + frameHeight.toUShort(), + ) + + public val windowMode: Int + get() = _windowMode.toInt() + public val frameWidth: Int + get() = _frameWidth.toInt() + public val frameHeight: Int + get() = _frameHeight.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WindowStatus + + if (_windowMode != other._windowMode) return false + if (_frameWidth != other._frameWidth) return false + if (_frameHeight != other._frameHeight) return false + + return true + } + + override fun hashCode(): Int { + var result = _windowMode.hashCode() + result = 31 * result + _frameWidth.hashCode() + result = 31 * result + _frameHeight.hashCode() + return result + } + + override fun toString(): String = + "WindowStatus(" + + "windowMode=$windowMode, " + + "frameWidth=$frameWidth, " + + "frameHeight=$frameHeight" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/BugReport.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/BugReport.kt new file mode 100644 index 000000000..e2a97ac6f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/BugReport.kt @@ -0,0 +1,66 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Bug report packets are sent when players submit a bug report + * using the bug report interface. + * @property type the type of the report. The only known value of this is 0. + * @property description the description of the bug, how it happened etc. + * The maximum length of this form is 500 characters, as the client prevents + * sending anything beyond that. + * @property instructions instructions on how to reproduce the bug. + * The maximum length of this form is also 500 characters, as the client + * prevents sending anything beyond that. + * The decoder will throw an exception if the length of the message exceeds + * the 500 length constraint, so no validation needs to be done on the user's end. + */ +public class BugReport private constructor( + private val _type: UByte, + public val description: String, + public val instructions: String, +) : IncomingGameMessage { + public constructor( + type: Int, + description: String, + instructions: String, + ) : this( + type.toUByte(), + description, + instructions, + ) + + public val type: Int + get() = _type.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BugReport + + if (_type != other._type) return false + if (description != other.description) return false + if (instructions != other.instructions) return false + + return true + } + + override fun hashCode(): Int { + var result = _type.hashCode() + result = 31 * result + description.hashCode() + result = 31 * result + instructions.hashCode() + return result + } + + override fun toString(): String = + "BugReport(" + + "description='$description', " + + "instructions='$instructions', " + + "type=$type" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClickWorldMap.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClickWorldMap.kt new file mode 100644 index 000000000..c541849b9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClickWorldMap.kt @@ -0,0 +1,55 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Click world map events are transmitted when the user double-clicks + * on the world map. If the user has J-Mod privileges and holds the + * 'Control' and 'Shift' keys down as they do the click, a different + * packet is transmitted instead. + * This packet is intended for a feature that never released - world + * map hints. In the pre-eoc days, players could double-click on their + * world map to set a 'Destination marker' which had a blue arrow to it, + * allowing them easier navigation to the given destination. + * In OldSchool RuneScape, there is a RuneLite plugin that accomplishes + * the same thing. Additionally, the double-clicking is fairly broken + * in the C++ client, and only sends this packet in some extreme cases + * when dragging the world map around, not through the traditional + * double-clicking. + * @property x the absolute x coordinate to set the destination to + * @property z the absolute z coordinate to set the destination to + * @property level the level to set the destination to + */ +public class ClickWorldMap( + private val coordGrid: CoordGrid, +) : IncomingGameMessage { + public val x: Int + get() = coordGrid.x + public val z: Int + get() = coordGrid.z + public val level: Int + get() = coordGrid.level + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClickWorldMap + + return coordGrid == other.coordGrid + } + + override fun hashCode(): Int = coordGrid.hashCode() + + override fun toString(): String = + "ClickWorldMap(" + + "x=$x, " + + "z=$z, " + + "level=$level" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClientCheat.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClientCheat.kt new file mode 100644 index 000000000..b8f891591 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/ClientCheat.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Client cheats are commands sent in chat using the :: prefix, + * or through the console on the C++ client. + */ +public class ClientCheat( + public val command: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClientCheat + + return command == other.command + } + + override fun hashCode(): Int = command.hashCode() + + override fun toString(): String = "ClientCheat(command='$command')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/CloseModal.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/CloseModal.kt new file mode 100644 index 000000000..90a3ec906 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/CloseModal.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Close modal messages are sent when the player clicks on the 'x' button + * of a modal interface, or if they press the 'Esc' key while having the + * "Esc to close interfaces" setting enabled. + */ +public data object CloseModal : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/HiscoreRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/HiscoreRequest.kt new file mode 100644 index 000000000..dff67c03b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/HiscoreRequest.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * A hiscore request message is sent when a player does a lookup of another + * player on the C++ clients. This functionality is currently not used in any way. + * @property type the type of the request (main, ironman, group ironman etc) + * The exact values are not yet known. + * @property requestId the id of the request + * @property name the name of the player whom to look up + */ +@Suppress("MemberVisibilityCanBePrivate") +public class HiscoreRequest( + private val _type: UByte, + private val _requestId: UByte, + public val name: String, +) : IncomingGameMessage { + public constructor( + type: Int, + requestId: Int, + name: String, + ) : this( + type.toUByte(), + requestId.toUByte(), + name, + ) + + public val type: Int + get() = _type.toInt() + public val requestId: Int + get() = _requestId.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HiscoreRequest + + if (_type != other._type) return false + if (_requestId != other._requestId) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = _type.hashCode() + result = 31 * result + _requestId.hashCode() + result = 31 * result + name.hashCode() + return result + } + + override fun toString(): String = + "HiscoreRequest(" + + "name='$name', " + + "type=$type, " + + "requestId=$requestId" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/IfCrmViewClick.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/IfCrmViewClick.kt new file mode 100644 index 000000000..551605220 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/IfCrmViewClick.kt @@ -0,0 +1,96 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * Content recommendation interface clicks happen when a player + * clicks a component on the CRM interface, which is currently only used + * in the form of the lobby interface, where user-specific advertisements + * are shown. + * Worth noting that the properties here are rough guesses at their naming + * and the real usage has not been tested in-game. + * @property crmServerTarget the server target, an integer + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id clicked on + * @property componentId the component id clicked on + * @property sub the subcomponent clicked on, or -1 if none exists + * @property behaviour1 the first CRM behaviour, an integer + * @property behaviour2 the second CRM behaviour, an integer + * @property behaviour3 the third CRM behaviour, an integer + */ +public class IfCrmViewClick private constructor( + public val crmServerTarget: Int, + private val _combinedId: CombinedId, + private val _sub: UShort, + public val behaviour1: Int, + public val behaviour2: Int, + public val behaviour3: Int, +) : IncomingGameMessage { + public constructor( + crmServerTarget: Int, + combinedId: CombinedId, + sub: Int, + behaviour1: Int, + behaviour2: Int, + behaviour3: Int, + ) : this( + crmServerTarget, + combinedId, + sub.toUShort(), + behaviour1, + behaviour2, + behaviour3, + ) + + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val sub: Int + get() = _sub.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfCrmViewClick + + if (crmServerTarget != other.crmServerTarget) return false + if (_combinedId != other._combinedId) return false + if (_sub != other._sub) return false + if (behaviour1 != other.behaviour1) return false + if (behaviour2 != other.behaviour2) return false + if (behaviour3 != other.behaviour3) return false + + return true + } + + override fun hashCode(): Int { + var result = crmServerTarget + result = 31 * result + _combinedId.hashCode() + result = 31 * result + _sub.hashCode() + result = 31 * result + behaviour1 + result = 31 * result + behaviour2 + result = 31 * result + behaviour3 + return result + } + + override fun toString(): String = + "IfCrmViewClick(" + + "crmServerTarget=$crmServerTarget, " + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "sub=$sub, " + + "behaviour1=$behaviour1, " + + "behaviour2=$behaviour2, " + + "behaviour3=$behaviour3" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveGameClick.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveGameClick.kt new file mode 100644 index 000000000..92e4d33a7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveGameClick.kt @@ -0,0 +1,67 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.game.incoming.misc.user.internal.MovementRequest +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Move gameclick packets are sent when the user clicks to walk within their + * main game window (not minimap). + * @property x the absolute x coordinate to walk to + * @property z the absolute z coordinate to walk to + * @property keyCombination the combination of keys held down to move there. + * Possible values include 0, 1 and 2, where: + * A value of 2 is sent if the user is holding down the 'Control' and 'Shift' keys + * simultaneously. + * A value of 1 is sent if the user is holding down the 'Control' key without + * the 'Shift' key. + * In any other scenario, a value of 0 is sent. + * The 'Control' key is used to invert move speed for the single movement request, + * and the 'Control' + 'Shift' combination is presumably for J-Mods to teleport + * around - although there are no validations for J-Mod privileges in the client, + * it will send the value of 2 even for regular users. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class MoveGameClick private constructor( + private val movementRequest: MovementRequest, +) : IncomingGameMessage { + public constructor( + x: Int, + z: Int, + keyCombination: Int, + ) : this( + MovementRequest( + x, + z, + keyCombination, + ), + ) + + public val x: Int + get() = movementRequest.x + public val z: Int + get() = movementRequest.z + public val keyCombination: Int + get() = movementRequest.keyCombination + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MoveGameClick + + return movementRequest == other.movementRequest + } + + override fun hashCode(): Int = movementRequest.hashCode() + + override fun toString(): String = + "MoveGameClick(" + + "x=$x, " + + "z=$z, " + + "keyCombination=$keyCombination" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveMinimapClick.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveMinimapClick.kt new file mode 100644 index 000000000..dec9e9566 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/MoveMinimapClick.kt @@ -0,0 +1,172 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.game.incoming.misc.user.internal.MovementRequest +import net.rsprot.protocol.message.IncomingGameMessage +import kotlin.math.cos +import kotlin.math.sin + +/** + * Move minimap click is sent when the player requests to walk somewhere + * through their minimap. + * While the packet itself sends additional constant values to the server, + * we do not store those values as they are expected to always be the same. + * The decoder will verify the values and throw an exception in decoding + * if those values do not align up. + * @property x the absolute x coordinate the player is walking to + * @property z the absolute z coordinate the player is walking to + * @property keyCombination the combination of keys held down to move there. + * Possible values include 0, 1 and 2, where: + * A value of 2 is sent if the user is holding down the 'Control' and 'Shift' keys + * simultaneously. + * A value of 1 is sent if the user is holding down the 'Control' key without + * the 'Shift' key. + * In any other scenario, a value of 0 is sent. + * The 'Control' key is used to invert move speed for the single movement request, + * and the 'Control' + 'Shift' combination is presumably for J-Mods to teleport + * around - although there are no validations for J-Mod privileges in the client, + * it will send the value of 2 even for regular users. + * @property minimapWidth the width of the minimap component in pixels + * @property minimapHeight the height of the minimap component in pixels + * @property cameraAngleY the angle of the camera + * @property fineX the fine x coordinate of the local player + * @property fineZ the fine z coordinate of the local player + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class MoveMinimapClick private constructor( + private val movementRequest: MovementRequest, + private val _minimapWidth: UByte, + private val _minimapHeight: UByte, + private val _cameraAngleY: UShort, + private val _fineX: UShort, + private val _fineZ: UShort, +) : IncomingGameMessage { + public constructor( + x: Int, + z: Int, + keyCombination: Int, + minimapWidth: Int, + minimapHeight: Int, + cameraAngleY: Int, + fineX: Int, + fineZ: Int, + ) : this( + MovementRequest( + x, + z, + keyCombination, + ), + minimapWidth.toUByte(), + minimapHeight.toUByte(), + cameraAngleY.toUShort(), + fineX.toUShort(), + fineZ.toUShort(), + ) + + public val x: Int + get() = movementRequest.x + public val z: Int + get() = movementRequest.z + public val keyCombination: Int + get() = movementRequest.keyCombination + public val minimapWidth: Int + get() = _minimapWidth.toInt() + public val minimapHeight: Int + get() = _minimapHeight.toInt() + public val cameraAngleY: Int + get() = _cameraAngleY.toInt() + public val fineX: Int + get() = _fineX.toInt() + public val fineZ: Int + get() = _fineZ.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + /** + * Checks if the provided arguments are valid (as in produce the same + * end coordinates if re-calculated). + * It is worth noting that the C++ client has zoom functionality for + * which the server does not get information, so it is not possible + * to verify this on the C++ clients. Additionally, clients such as + * RuneLite have their own built-in zoom and also do not correct the + * packet itself. For these reasons, the verification is not done by + * the library, but it could provide a useful bit of information for + * complete vanilla builds. + * @param baseZoneX the south-western zone x coordinate of the build area + * @param baseZoneZ the south-western zone z coordinate of the build area + * The base zone coordinates are relative to the build-area that the client + * builds around. If the player logs in at absolute coordinates 3200, 3220, + * their baseZoneX would be ((3200 - (6 * 8)) / 8), and the baseZoneZ + * would be ((3220 - (6 * 8)) / 8), resulting in base zone coordinates of + * 394, 396. The (6 * 8) is the normal subtraction to go from the center + * of the build-area to the south-western corner, as a value of 48 is + * subtracted in the case of a size 104 build-area. + */ + public fun isValid( + baseZoneX: Int, + baseZoneZ: Int, + ): Boolean { + val minimapAngle = cameraAngleY and 0x7FF + val sine = sine[minimapAngle] + val cosine = cosine[minimapAngle] + val minimapX = ((cosine * minimapWidth) + (sine * minimapHeight)) shr 11 + val minimapY = ((cosine * minimapHeight) - (sine * minimapWidth)) shr 11 + val localX = (minimapX + fineX) shr 7 + val localY = (fineZ - minimapY) shr 7 + val calculatedDestX = (baseZoneX shl 3) + localX + val calculatedDestZ = (baseZoneZ shl 3) + localY + return calculatedDestX == x && calculatedDestZ == z + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MoveMinimapClick + + if (movementRequest != other.movementRequest) return false + if (_minimapWidth != other._minimapWidth) return false + if (_minimapHeight != other._minimapHeight) return false + if (_cameraAngleY != other._cameraAngleY) return false + if (_fineX != other._fineX) return false + if (_fineZ != other._fineZ) return false + + return true + } + + override fun hashCode(): Int { + var result = movementRequest.hashCode() + result = 31 * result + _minimapWidth.hashCode() + result = 31 * result + _minimapHeight.hashCode() + result = 31 * result + _cameraAngleY.hashCode() + result = 31 * result + _fineX.hashCode() + result = 31 * result + _fineZ.hashCode() + return result + } + + override fun toString(): String = + "MoveMinimapClick(" + + "x=$x, " + + "z=$z, " + + "keyCombination=$keyCombination, " + + "width=$minimapWidth, " + + "height=$minimapHeight, " + + "cameraAngleY=$cameraAngleY, " + + "fineX=$fineX, " + + "fineZ=$fineZ" + + ")" + + private companion object { + private const val MAX_ANGLE = 65536.0 + private const val CONSTANT = 0.0030679615 + private val sine: IntArray = + IntArray(2048) { + (MAX_ANGLE * sin(it * CONSTANT)).toInt() + } + private val cosine: IntArray = + IntArray(2048) { + (MAX_ANGLE * cos(it * CONSTANT)).toInt() + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/OculusLeave.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/OculusLeave.kt new file mode 100644 index 000000000..79f517656 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/OculusLeave.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Oculus leave message is sent when the player presses the 'Esc' key + * to exit the orb of oculus view. + */ +public data object OculusLeave : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SendSnapshot.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SendSnapshot.kt new file mode 100644 index 000000000..179dd4804 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SendSnapshot.kt @@ -0,0 +1,92 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Send snapshot message is sent when a player reports another player. + * + * Rules table: + * ``` + * | Id | Rule | + * |----|:-------------------------------------------:| + * | 3 | Exploiting a bug | + * | 4 | Staff impersonation | + * | 5 | Buying/selling accounts and services | + * | 6 | Macroing or use of bots | + * | 7 | Boxing in the Deadman Tournament | + * | 8 | Encouraging rule breaking | + * | 10 | Advertising websites | + * | 11 | Muling in the Deadman Tournament | + * | 12 | Asking for or providing contact information | + * | 14 | Scamming | + * | 15 | Seriously offensive language | + * | 16 | Solicitation | + * | 17 | Disruptive behaviour | + * | 18 | Offensive account name | + * | 19 | Real-life threats | + * | 20 | Breaking real-world laws | + * | 21 | Player-run Games of Chance | + * ``` + * + * @property name the name of the player that is being reported + * @property ruleId the rule that the player broke (see table above). + * Note that the rule ids are internal and not what one sees on the interface, + * as the rule ids must be persistent across years of usage. Additionally, + * the "Boxing in Deadman Tournament" and "Muling in the Deadman Tournament" + * rules can only be selected if the player is logged into a Deadman world. + * Additionally worth noting that the rule ids are 1 less than what is shown + * in clientscripts, as the clientscript command behind sending the snapshot + * decrements 1 from the value prior to submitting it to the server. + * @property mute whether to mute the player. This option is only possible + * by Player and Jagex moderators. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class SendSnapshot private constructor( + public val name: String, + private val _ruleId: UByte, + public val mute: Boolean, +) : IncomingGameMessage { + public constructor( + name: String, + ruleId: Int, + mute: Boolean, + ) : this( + name, + ruleId.toUByte(), + mute, + ) + + public val ruleId: Int + get() = _ruleId.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SendSnapshot + + if (name != other.name) return false + if (_ruleId != other._ruleId) return false + if (mute != other.mute) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _ruleId.hashCode() + result = 31 * result + mute.hashCode() + return result + } + + override fun toString(): String = + "SendSnapshot(" + + "name='$name', " + + "ruleId=$ruleId, " + + "mute=$mute" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetChatFilterSettings.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetChatFilterSettings.kt new file mode 100644 index 000000000..4b780ad6f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetChatFilterSettings.kt @@ -0,0 +1,80 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Set chat filter settings is sent when the player changes either their + * public, private or trade filters, in order to synchronize the status + * with the server. + * + * Chat filters table: + * ``` + * | Id | Type | + * |----|:--------:| + * | 0 | On | + * | 1 | Friends | + * | 2 | Off | + * | 3 | Hide | + * | 4 | Autochat | + * ``` + * + * @property publicChatFilter the public chat filter status, any value in the above table + * @property privateChatFilter the private chat filter status, allowed values include + * 'On', 'Friends' and 'Off' (see table above) + * @property tradeChatFilter the trade chat filter status, allowed values include + * 'On', 'Friends' and 'Off' (see table above) + */ +@Suppress("MemberVisibilityCanBePrivate") +public class SetChatFilterSettings private constructor( + private val _publicChatFilter: UByte, + private val _privateChatFilter: UByte, + private val _tradeChatFilter: UByte, +) : IncomingGameMessage { + public constructor( + publicChatFilter: Int, + privateChatFilter: Int, + tradeChatFilter: Int, + ) : this( + publicChatFilter.toUByte(), + privateChatFilter.toUByte(), + tradeChatFilter.toUByte(), + ) + + public val publicChatFilter: Int + get() = _publicChatFilter.toInt() + public val privateChatFilter: Int + get() = _privateChatFilter.toInt() + public val tradeChatFilter: Int + get() = _tradeChatFilter.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetChatFilterSettings + + if (_publicChatFilter != other._publicChatFilter) return false + if (_privateChatFilter != other._privateChatFilter) return false + if (_tradeChatFilter != other._tradeChatFilter) return false + + return true + } + + override fun hashCode(): Int { + var result = _publicChatFilter.hashCode() + result = 31 * result + _privateChatFilter.hashCode() + result = 31 * result + _tradeChatFilter.hashCode() + return result + } + + override fun toString(): String = + "SetChatFilterSettings(" + + "publicChatFilter=$publicChatFilter, " + + "privateChatFilter=$privateChatFilter, " + + "tradeChatFilter=$tradeChatFilter" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetHeading.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetHeading.kt new file mode 100644 index 000000000..f13b482ee --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/SetHeading.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Set heading packet is used to update the current heading/angle/direction of the worldentity. + * @property heading the heading in which to turn the worldentity. A value of 0-15 (inclusive). + * This value is a scaled down variant of the 0-2048 angle that is normally used, except the value is + * divided by 128. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class SetHeading( + public val heading: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SetHeading) return false + + if (heading != other.heading) return false + + return true + } + + override fun hashCode(): Int { + return heading + } + + override fun toString(): String { + return "SetHeading(" + + "heading=$heading" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/Teleport.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/Teleport.kt new file mode 100644 index 000000000..ddc1070d1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/Teleport.kt @@ -0,0 +1,82 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Teleport packets are sent in multiple possible scenarios: + * 1. The player is a J-Mod and has the 'Control' and 'Shift' keys held down + * while scrolling with their mouse wheel - the player will be teleported + * up or down a level. + * 2. The player is a J-Mod using an Orb of Oculus - the teleport packet + * will be sent repeatedly every 50 client cycles (20ms/cc) while the player's + * coordinate doesn't align up with the oculus camera center coordinate. + * 3. The player is a J-Mod and has the 'Control' and 'Shift' keys held down + * while clicking on the world map - the player will teleport to the coordinate + * they clicked on in the world map. + * @property oculusSyncValue if the player is in orb of oculus (scenario 2 above), + * this value is equal to the last value the server transmitted with the + * [net.rsprot.protocol.game.outgoing.prot.GameServerProt.OCULUS_SYNC] packet, + * or 0 if the packet was never transmitted/player is not using orb of oculus. + * @property x the absolute x coordinate to teleport to + * @property z the absolute z coordinate to teleport to + * @property level the height level to teleport to + */ +public class Teleport private constructor( + public val oculusSyncValue: Int, + private val _x: UShort, + private val _z: UShort, + private val _level: UByte, +) : IncomingGameMessage { + public constructor( + oculusSyncValue: Int, + x: Int, + z: Int, + level: Int, + ) : this( + oculusSyncValue, + x.toUShort(), + z.toUShort(), + level.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val level: Int + get() = _level.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Teleport + + if (oculusSyncValue != other.oculusSyncValue) return false + if (_x != other._x) return false + if (_z != other._z) return false + if (_level != other._level) return false + + return true + } + + override fun hashCode(): Int { + var result = oculusSyncValue + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _level.hashCode() + return result + } + + override fun toString(): String = + "Teleport(" + + "oculusSyncValue=$oculusSyncValue, " + + "x=$x, " + + "z=$z, " + + "level=$level" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/UpdatePlayerModelV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/UpdatePlayerModelV1.kt new file mode 100644 index 000000000..4b3d124b5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/UpdatePlayerModelV1.kt @@ -0,0 +1,102 @@ +package net.rsprot.protocol.game.incoming.misc.user + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import kotlin.jvm.Throws + +/** + * Update player model packet is sent for the old make-over interface, + * when the player finishes designing their character. It should be noted, + * that this is no longer in use in OldSchool RuneScape, as a newer interface + * uses traditional buttons to manage it. However, as this is still a valid + * packet that can be sent by the server, we've implemented it. + * @property bodyType the body type of the player + * @property identKits the ident kits the player can customize + * @property colours the colours the player can customize + */ +@Suppress("MemberVisibilityCanBePrivate") +public class UpdatePlayerModelV1 private constructor( + private val _bodyType: UByte, + private val identKits: ByteArray, + private val colours: ByteArray, +) : IncomingGameMessage { + public constructor( + bodyType: Int, + identKits: ByteArray, + colours: ByteArray, + ) : this( + bodyType.toUByte(), + identKits, + colours, + ) + + public val bodyType: Int + get() = _bodyType.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + /** + * Gets the backing ident kits byte array. + * Changes done to this byte array reflect on this packet. + */ + public fun getIdentKitsByteArray(): ByteArray = identKits + + /** + * Gets the backing colours byte array. + * Changes done to this byte array reflect on the packet. + */ + public fun getColoursByteArray(): ByteArray = colours + + /** + * Gets the ident kit at index [index], or -1 if it doesn't exist. + * @param index the index of the body part + * @return ident kit at that body part, or -1 if it doesn't exist + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= 7 + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getIdentKit(index: Int): Int { + val value = identKits[index].toInt() + return if (value == 0xFF) { + -1 + } else { + value + } + } + + /** + * Gets the colour at index [index], or -1 if it doesn't exist. + * @param index the index of the colour + * @return colour at that index, or -1 if it doesn't exist + * @throws ArrayIndexOutOfBoundsException if the index is below 0, or >= 5 + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getColour(index: Int): Int = colours[index].toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdatePlayerModelV1 + + if (_bodyType != other._bodyType) return false + if (!identKits.contentEquals(other.identKits)) return false + if (!colours.contentEquals(other.colours)) return false + + return true + } + + override fun hashCode(): Int { + var result = _bodyType.hashCode() + result = 31 * result + identKits.contentHashCode() + result = 31 * result + colours.contentHashCode() + return result + } + + override fun toString(): String = + "UpdatePlayerModelV1(" + + "bodyType=$bodyType, " + + "identKits=${identKits.contentToString()}, " + + "colours=${colours.contentToString()}" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/internal/MovementRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/internal/MovementRequest.kt new file mode 100644 index 000000000..b70b28283 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/misc/user/internal/MovementRequest.kt @@ -0,0 +1,34 @@ +package net.rsprot.protocol.game.incoming.misc.user.internal + +/** + * A value class around an int to bitpack all the properties of move gameclick + * into a single integer. This is primarily to constrain our class to a payload + * of 4 bytes, as going above it means being subject to increased memory alignment. + * As mentioned in documentation before, empty objects allocate 12 bytes, but + * get aligned to a multiple of 8 bytes - so they will consume 16 bytes. + * Putting an int inside the class would allocate 16 bytes, and remain as 16 + * after padding. Allocating 17 bytes (ref: 12, x: 2, y: 2, key: 1), + * however, would mean the class is subject to being padded to 24 bytes, with 7 of + * them being completely wasted in the process. + */ +@JvmInline +internal value class MovementRequest private constructor( + private val packed: Int, +) { + internal constructor( + x: Int, + z: Int, + keyCombination: Int, + ) : this( + (z and 0x3FFF) + .or(x and 0x3FFF shl 14) + .or(keyCombination and 0x3 shl 28), + ) + + val x: Int + get() = packed ushr 14 and 0x3FFF + val z: Int + get() = packed and 0x3FFF + val keyCombination: Int + get() = packed ushr 28 and 0x3 +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc.kt new file mode 100644 index 000000000..a6bb11d61 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.incoming.npcs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpNpc messages are sent when a player clicks one of the five primary options on a NPC. + * It should be noted that this message will not handle 'OPNPC6', as that message requires + * different arguments. + * @property index the index of the npc that was clicked + * @property controlKey whether the control key was held down, used to invert movement speed + * @property op the option clicked, ranging from 1 to 5(inclusive). + */ +@Suppress("MemberVisibilityCanBePrivate") +public class OpNpc private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + op: Int, + ) : this( + index.toUShort(), + controlKey, + op.toUByte(), + ) + + public val index: Int + get() = _index.toInt() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpNpc + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "OpNpc(" + + "index=$index, " + + "controlKey=$controlKey, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc6.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc6.kt new file mode 100644 index 000000000..0e9c32eda --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpc6.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.npcs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpNpc6 message is fired when a player clicks the 'Examine' option on a npc. + * @property id the config id of the npc clicked + */ +public class OpNpc6( + public val id: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpNpc6 + + return id == other.id + } + + override fun hashCode(): Int = id + + override fun toString(): String = "OpNpc6(id=$id)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpcT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpcT.kt new file mode 100644 index 000000000..2eca06796 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/npcs/OpNpcT.kt @@ -0,0 +1,91 @@ +package net.rsprot.protocol.game.incoming.npcs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * OpNpcT messages are fired whenever an interface component is targeted + * on a NPC, which, as of revision 204, includes using items from + * the player's inventory on NPCs - the OpNpcU message was deprecated. + * @property index the index of the npc the component was used on + * @property controlKey whether the control key was held down, used to invert movement speed + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id of the selected component + * @property selectedComponentId the component id being used on the npc + * @property selectedSub the subcomponent of the selected component, or -1 of none exists + * @property selectedObj the obj on the selected subcomponent, or -1 if none exists + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpNpcT private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + ) : this( + index.toUShort(), + controlKey, + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpNpcT + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + return result + } + + override fun toString(): String = + "OpNpcT(" + + "index=$index, " + + "controlKey=$controlKey, " + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj.kt new file mode 100644 index 000000000..a6d8b286e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.game.incoming.objs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpObj messages are fired when the player interacts with an obj on the ground, + * e.g. picking an obj up off the ground. This does not include examining objs. + * @property id the id of the obj interacted with + * @property x the absolute x coordinate of the obj on the ground + * @property z the absolute z coordinate of the obj on the ground + * @property controlKey whether the control key was held down, used to invert movement speed + * @property op the option clicked, ranging from 1 to 5 (inclusive) + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpObj private constructor( + private val _id: UShort, + private val _x: UShort, + private val _z: UShort, + public val controlKey: Boolean, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + id: Int, + x: Int, + z: Int, + controlKey: Boolean, + op: Int, + ) : this( + id.toUShort(), + x.toUShort(), + z.toUShort(), + controlKey, + op.toUByte(), + ) + + public val id: Int + get() = _id.toInt() + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpObj + + if (_id != other._id) return false + if (_x != other._x) return false + if (_z != other._z) return false + if (controlKey != other.controlKey) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "OpObj(" + + "id=$id, " + + "x=$x, " + + "z=$z, " + + "controlKey=$controlKey, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj6.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj6.kt new file mode 100644 index 000000000..47d6ad3d5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObj6.kt @@ -0,0 +1,63 @@ +package net.rsprot.protocol.game.incoming.objs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpObj6 messages are fired whenever a player examines an obj on the ground. + * @property id the id of the obj examined + * @property x the absolute x coordinate of the obj on the ground + * @property z the absolute z coordinate of the obj on the ground + */ +public class OpObj6 private constructor( + private val _id: UShort, + private val _x: UShort, + private val _z: UShort, +) : IncomingGameMessage { + public constructor( + id: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + x.toUShort(), + z.toUShort(), + ) + + public val id: Int + get() = _id.toInt() + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpObj6 + + if (_id != other._id) return false + if (_x != other._x) return false + if (_z != other._z) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + return result + } + + override fun toString(): String = + "OpObj6(" + + "id=$id, " + + "x=$x, " + + "z=$z" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObjT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObjT.kt new file mode 100644 index 000000000..61ac1ae60 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/objs/OpObjT.kt @@ -0,0 +1,109 @@ +package net.rsprot.protocol.game.incoming.objs + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * OpObjT messages are fired whenever an interface component is targeted + * on an obj on the ground, which, as of revision 204, includes using items from + * the player's inventory on objs - the OpObjU message was deprecated. + * @property id the id of the obj the component was used on + * @property x the absolute x coordinate of the obj + * @property z the absolute z coordinate of the obj + * @property controlKey whether the control key was held down, used to invert movement speed + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id of the selected component + * @property selectedComponentId the component id being used on the obj + * @property selectedSub the subcomponent of the selected component, or -1 of none exists + * @property selectedObj the obj on the selected subcomponent, or -1 if none exists + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpObjT private constructor( + private val _id: UShort, + private val _x: UShort, + private val _z: UShort, + public val controlKey: Boolean, + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, +) : IncomingGameMessage { + public constructor( + id: Int, + x: Int, + z: Int, + controlKey: Boolean, + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + ) : this( + id.toUShort(), + x.toUShort(), + z.toUShort(), + controlKey, + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + ) + + public val id: Int + get() = _id.toInt() + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpObjT + + if (_id != other._id) return false + if (_x != other._x) return false + if (_z != other._z) return false + if (controlKey != other.controlKey) return false + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + return result + } + + override fun toString(): String = + "OpObjT(" + + "id=$id, " + + "x=$x, " + + "z=$z, " + + "controlKey=$controlKey, " + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayer.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayer.kt new file mode 100644 index 000000000..9584e3b1c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayer.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.incoming.players + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Opplayer messages are fired whenever a player clicks an option on another player, + * or if messages such as "* wishes to trade with you." are clicked. + * In the case of latter, only ops 1, 4, 6 and 7 will fire the packet. + * @property index the index of the player who was interacted with + * @property controlKey whether the control key was held down, used to invert movement speed + * @property op the option clicked, ranging from 1 to 8 (inclusive) + */ +@Suppress("MemberVisibilityCanBePrivate") +public class OpPlayer private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + op: Int, + ) : this( + index.toUShort(), + controlKey, + op.toUByte(), + ) + + public val index: Int + get() = _index.toInt() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpPlayer + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "OpPlayer(" + + "index=$index, " + + "controlKey=$controlKey, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayerT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayerT.kt new file mode 100644 index 000000000..b388b5952 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/players/OpPlayerT.kt @@ -0,0 +1,91 @@ +package net.rsprot.protocol.game.incoming.players + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * OpPlayerT messages are fired whenever an interface component is targeted + * on another player, which, as of revision 204, includes using items from + * the player's inventory on players - the OpPlayerU message was deprecated. + * @property index the index of the player clicked on + * @property controlKey whether the control key was held down, used to invert movement speed + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id of the selected component + * @property selectedComponentId the component id being used on the player + * @property selectedSub the subcomponent of the selected component, or -1 of none exists + * @property selectedObj the obj on the selected subcomponent, or -1 if none exists + */ +@Suppress("MemberVisibilityCanBePrivate", "DuplicatedCode") +public class OpPlayerT private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + ) : this( + index.toUShort(), + controlKey, + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpPlayerT + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + return result + } + + override fun toString(): String = + "OpPlayerT(" + + "index=$index, " + + "controlKey=$controlKey, " + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePCountDialog.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePCountDialog.kt new file mode 100644 index 000000000..52fbde079 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePCountDialog.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.game.incoming.resumed + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Resume p count dialogue is sent whenever a player enters an + * integer to the input box, e.g. to withdraw an item in x-quantity. + * @property count the count entered. While this can only be a positive + * integer for manually-entered inputs, it is **not** guaranteed to always + * be positive. Clientscripts can invoke this event with negative values to + * represent various potential response codes. + */ +public class ResumePCountDialog( + public val count: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResumePCountDialog + + return count == other.count + } + + override fun hashCode(): Int = count + + override fun toString(): String = "ResumePCountDialog(count=$count)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePNameDialog.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePNameDialog.kt new file mode 100644 index 000000000..c3bf7c4a3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePNameDialog.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.resumed + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Name dialogs are sent whenever a player enters the name of a player + * into the chatbox input box, e.g. to enter someone else's player-owned + * house. + * @property name the name of the player entered + */ +public class ResumePNameDialog( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResumePNameDialog + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "ResumePNameDialog(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePObjDialog.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePObjDialog.kt new file mode 100644 index 000000000..771f042e6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePObjDialog.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.incoming.resumed + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Resume p obj dialogue is sent when the user selects an obj from the + * Grand Exchange item search box, however this packet is not necessarily + * exclusive to that feature, and can be used in other pieces of content. + * @property obj the id of the obj selected + */ +public class ResumePObjDialog( + public val obj: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResumePObjDialog + + return obj == other.obj + } + + override fun hashCode(): Int = obj + + override fun toString(): String = "ResumePObjDialog(obj=$obj)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePStringDialog.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePStringDialog.kt new file mode 100644 index 000000000..5de817c90 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePStringDialog.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.resumed + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * String dialogs are sent whenever a player enters a string into + * the input box, e.g. for wiki search or diango's item code service. + * @property string the string entered + */ +public class ResumePStringDialog( + public val string: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResumePStringDialog + + return string == other.string + } + + override fun hashCode(): Int = string.hashCode() + + override fun toString(): String = "ResumePStringDialog(string='$string')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePauseButton.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePauseButton.kt new file mode 100644 index 000000000..041d2dc54 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/resumed/ResumePauseButton.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.incoming.resumed + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * Resume pausebutton messages are sent when the player continues + * a dialogue through the "Click to continue" button + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface on which the component exists + * @property componentId the component id clicked + * @property sub the subcomponent id, or -1 if it doesn't exist + */ +public class ResumePauseButton private constructor( + private val _combinedId: CombinedId, + private val _sub: UShort, +) : IncomingGameMessage { + public constructor( + combinedId: CombinedId, + sub: Int, + ) : this( + combinedId, + sub.toUShort(), + ) + + public val combinedId: Int + get() = _combinedId.combinedId + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val sub: Int + get() = _sub.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResumePauseButton + + if (_combinedId != other._combinedId) return false + if (_sub != other._sub) return false + + return true + } + + override fun hashCode(): Int { + var result = _combinedId.hashCode() + result = 31 * result + _sub.hashCode() + return result + } + + override fun toString(): String = + "ResumePauseButton(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "sub=$sub" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListAdd.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListAdd.kt new file mode 100644 index 000000000..108428658 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListAdd.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.social + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Friend list add messages are sent when the player requests + * to add another player to their friend list + * @property name the name of the player to add to the friend list + */ +public class FriendListAdd( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendListAdd + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "FriendListAdd(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListDel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListDel.kt new file mode 100644 index 000000000..307809a89 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/FriendListDel.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.social + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Friend list deletion messages are sent whenever the player + * requests to delete another user from their friend list. + * @property name the name of the player to delete + */ +public class FriendListDel( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendListDel + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "FriendListDel(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListAdd.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListAdd.kt new file mode 100644 index 000000000..5c25ee265 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListAdd.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.social + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Ignore list addition events are sent whenever the player + * requests to add another player to their ignorelist + * @property name the name of the player to add to their ignorelist + */ +public class IgnoreListAdd( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IgnoreListAdd + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "IgnoreListAdd(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListDel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListDel.kt new file mode 100644 index 000000000..6dc6ffd45 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/social/IgnoreListDel.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.incoming.social + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * Ignore list deletion messages are sent whenever the player + * requests to delete another player from their ignorelist + * @property name the name of the player to delete from their ignorelist + */ +public class IgnoreListDel( + public val name: String, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IgnoreListDel + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "IgnoreListDel(name='$name')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity.kt new file mode 100644 index 000000000..cedd07fc3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.incoming.worldentities + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpWorldEntity messages are sent when a player clicks one of the five primary options on a worldentity. + * It should be noted that this message will not handle 'OPWORLDENTITY6', as that message requires + * different arguments. + * @property index the index of the worldentity that was clicked + * @property controlKey whether the control key was held down, used to invert movement speed + * @property op the option clicked, ranging from 1 to 5(inclusive). + */ +@Suppress("MemberVisibilityCanBePrivate") +public class OpWorldEntity private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _op: UByte, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + op: Int, + ) : this( + index.toUShort(), + controlKey, + op.toUByte(), + ) + + public val index: Int + get() = _index.toInt() + public val op: Int + get() = _op.toInt() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpWorldEntity + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_op != other._op) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _op.hashCode() + return result + } + + override fun toString(): String = + "OpWorldEntity(" + + "index=$index, " + + "controlKey=$controlKey, " + + "op=$op" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity6.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity6.kt new file mode 100644 index 000000000..9ac15d6d0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntity6.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.incoming.worldentities + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage + +/** + * OpWorldEntity6 message is fired when a player clicks the 'Examine' option on a worldentity. + * @property id the config id of the worldentity clicked + */ +public class OpWorldEntity6( + public val id: Int, +) : IncomingGameMessage { + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpWorldEntity6 + + return id == other.id + } + + override fun hashCode(): Int = id + + override fun toString(): String = "OpWorldEntity6(id=$id)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntityT.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntityT.kt new file mode 100644 index 000000000..bde7a3574 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/incoming/worldentities/OpWorldEntityT.kt @@ -0,0 +1,91 @@ +package net.rsprot.protocol.game.incoming.worldentities + +import net.rsprot.protocol.ClientProtCategory +import net.rsprot.protocol.game.incoming.GameClientProtCategory +import net.rsprot.protocol.message.IncomingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * OpWorldEntityT messages are fired whenever an interface component is targeted + * on a world entity, which, as of revision 204, includes using items from + * the player's inventory on World Entities - the OpWorldEntityU message was deprecated. + * @property index the index of the worldentity the component was used on + * @property controlKey whether the control key was held down, used to invert movement speed + * @property selectedCombinedId the bitpacked combination of [selectedInterfaceId] and [selectedComponentId]. + * @property selectedInterfaceId the interface id of the selected component + * @property selectedComponentId the component id being used on the worldentity + * @property selectedSub the subcomponent of the selected component, or -1 of none exists + * @property selectedObj the obj on the selected subcomponent, or -1 if none exists + */ +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class OpWorldEntityT private constructor( + private val _index: UShort, + public val controlKey: Boolean, + private val _selectedCombinedId: CombinedId, + private val _selectedSub: UShort, + private val _selectedObj: UShort, +) : IncomingGameMessage { + public constructor( + index: Int, + controlKey: Boolean, + selectedCombinedId: CombinedId, + selectedSub: Int, + selectedObj: Int, + ) : this( + index.toUShort(), + controlKey, + selectedCombinedId, + selectedSub.toUShort(), + selectedObj.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val selectedCombinedId: Int + get() = _selectedCombinedId.combinedId + public val selectedInterfaceId: Int + get() = _selectedCombinedId.interfaceId + public val selectedComponentId: Int + get() = _selectedCombinedId.componentId + public val selectedSub: Int + get() = _selectedSub.toIntOrMinusOne() + public val selectedObj: Int + get() = _selectedObj.toIntOrMinusOne() + override val category: ClientProtCategory + get() = GameClientProtCategory.USER_EVENT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OpWorldEntityT + + if (_index != other._index) return false + if (controlKey != other.controlKey) return false + if (_selectedCombinedId != other._selectedCombinedId) return false + if (_selectedSub != other._selectedSub) return false + if (_selectedObj != other._selectedObj) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + controlKey.hashCode() + result = 31 * result + _selectedCombinedId.hashCode() + result = 31 * result + _selectedSub.hashCode() + result = 31 * result + _selectedObj.hashCode() + return result + } + + override fun toString(): String = + "OpWorldEntityT(" + + "index=$index, " + + "controlKey=$controlKey, " + + "selectedInterfaceId=$selectedInterfaceId, " + + "selectedComponentId=$selectedComponentId, " + + "selectedSub=$selectedSub, " + + "selectedObj=$selectedObj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/GameServerProtCategory.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/GameServerProtCategory.kt new file mode 100644 index 000000000..1cf993719 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/GameServerProtCategory.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.outgoing + +import net.rsprot.protocol.ServerProtCategory + +public enum class GameServerProtCategory( + override val id: Int, +) : ServerProtCategory { + HIGH_PRIORITY_PROT(0), + LOW_PRIORITY_PROT(1), + ; + + public companion object { + public const val COUNT: Int = 2 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV1.kt new file mode 100644 index 000000000..9f40cd0e0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV1.kt @@ -0,0 +1,84 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam look at eased coord is used to make the camera look towards + * a certain coordinate with various easing functions. + * + * @property destinationXInBuildArea the dest x coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property destinationZInBuildArea the dest z coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property height the height of the camera + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +public class CamLookAtEasedCoordV1 private constructor( + private val destinationCoordInBuildArea: CoordInBuildArea, + private val _height: UShort, + private val _cycles: UShort, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + xInBuildArea: Int, + zInBuildArea: Int, + height: Int, + cycles: Int, + easing: Int, + ) : this( + CoordInBuildArea(xInBuildArea, zInBuildArea), + height.toUShort(), + cycles.toUShort(), + easing.toUByte(), + ) + + public val destinationXInBuildArea: Int + get() = destinationCoordInBuildArea.xInBuildArea + public val destinationZInBuildArea: Int + get() = destinationCoordInBuildArea.zInBuildArea + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamLookAtEasedCoordV1 + + if (destinationCoordInBuildArea != other.destinationCoordInBuildArea) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = destinationCoordInBuildArea.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String = + "CamLookAtEasedCoordV1(" + + "destinationXInBuildArea=$destinationXInBuildArea, " + + "destinationZInBuildArea=$destinationZInBuildArea, " + + "height=$height, " + + "cycles=$cycles, " + + "easing=$easing" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV2.kt new file mode 100644 index 000000000..d95605ae7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtEasedCoordV2.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam look at eased coord is used to make the camera look towards + * a certain coordinate with various easing functions. + * + * @property x the absolute x coordinate to look at. + * @property z the absolute z coordinate to look at. + * @property height the height of the camera + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +public class CamLookAtEasedCoordV2 private constructor( + private val _x: UShort, + private val _z: UShort, + private val _height: UShort, + private val _cycles: UShort, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + x: Int, + z: Int, + height: Int, + cycles: Int, + easing: Int, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUShort(), + cycles.toUShort(), + easing.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamLookAtEasedCoordV2 + + if (_x != other._x) return false + if (_z != other._z) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String { + return "CamLookAtEasedCoordV2(" + + "x=$x, " + + "z=$z, " + + "height=$height, " + + "cycles=$cycles, " + + "easing=$easing" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV1.kt new file mode 100644 index 000000000..e0354ac13 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV1.kt @@ -0,0 +1,88 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam lookat packet is used to make the camera look towards + * a certain coordinate in the build area. + * It is important to note that if this is sent together with + * a map reload, whether this packet comes before or after the + * map reload makes a difference - as the build area itself changes. + * + * @property destinationXInBuildArea the dest x coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property destinationZInBuildArea the dest z coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property height the height of the camera + * @property rate the constant speed at which the camera looks towards + * to the new coordinate + * @property rate2 the speed increase as the camera looks + * towards the end coordinate. + */ +public class CamLookAtV1 private constructor( + private val destinationCoordInBuildArea: CoordInBuildArea, + private val _height: UShort, + private val _rate: UByte, + private val _rate2: UByte, +) : OutgoingGameMessage { + public constructor( + xInBuildArea: Int, + zInBuildArea: Int, + height: Int, + rate: Int, + rate2: Int, + ) : this( + CoordInBuildArea(xInBuildArea, zInBuildArea), + height.toUShort(), + rate.toUByte(), + rate2.toUByte(), + ) + + public val destinationXInBuildArea: Int + get() = destinationCoordInBuildArea.xInBuildArea + public val destinationZInBuildArea: Int + get() = destinationCoordInBuildArea.zInBuildArea + public val height: Int + get() = _height.toInt() + public val rate: Int + get() = _rate.toInt() + public val rate2: Int + get() = _rate2.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamLookAtV1 + + if (destinationCoordInBuildArea != other.destinationCoordInBuildArea) return false + if (_height != other._height) return false + if (_rate != other._rate) return false + if (_rate2 != other._rate2) return false + + return true + } + + override fun hashCode(): Int { + var result = destinationCoordInBuildArea.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _rate.hashCode() + result = 31 * result + _rate2.hashCode() + return result + } + + override fun toString(): String = + "CamLookAtV1(" + + "destinationXInBuildArea=$destinationXInBuildArea, " + + "destinationZInBuildArea=$destinationZInBuildArea, " + + "height=$height, " + + "rate=$rate, " + + "rate2=$rate2" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV2.kt new file mode 100644 index 000000000..4387dc9a0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamLookAtV2.kt @@ -0,0 +1,87 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam lookat packet is used to make the camera look towards + * a certain coordinate in the root world. + * + * @property x the absolute x coordinate to look at. + * @property z the absolute z coordinate to look at. + * @property height the height of the camera + * @property rate the constant speed at which the camera looks towards + * to the new coordinate + * @property rate2 the speed increase as the camera looks + * towards the end coordinate. + */ +public class CamLookAtV2 private constructor( + private val _x: UShort, + private val _z: UShort, + private val _height: UShort, + private val _rate: UByte, + private val _rate2: UByte, +) : OutgoingGameMessage { + public constructor( + x: Int, + z: Int, + height: Int, + rate: Int, + rate2: Int, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUShort(), + rate.toUByte(), + rate2.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val height: Int + get() = _height.toInt() + public val rate: Int + get() = _rate.toInt() + public val rate2: Int + get() = _rate2.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamLookAtV2 + + if (_x != other._x) return false + if (_z != other._z) return false + if (_height != other._height) return false + if (_rate != other._rate) return false + if (_rate2 != other._rate2) return false + + return true + } + + override fun hashCode(): Int { + var result = _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _rate.hashCode() + result = 31 * result + _rate2.hashCode() + return result + } + + override fun toString(): String { + return "CamLookAtV2(" + + "x=$x, " + + "z=$z, " + + "height=$height, " + + "rate=$rate, " + + "rate2=$rate2" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMode.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMode.kt new file mode 100644 index 000000000..557047764 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMode.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam mode is used to set the camera into an orb-of-oculus mode, + * or out of it. + * @property mode the mode to set in, with the only valid values being + * 0 for "out of oculus" and 1 for "into oculus". + */ +public class CamMode( + public val mode: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMode + + return mode == other.mode + } + + override fun hashCode(): Int = mode + + override fun toString(): String = "CamMode(mode=$mode)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV1.kt new file mode 100644 index 000000000..10f91902a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV1.kt @@ -0,0 +1,117 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Camera move to arc packet is used to move camera + * to a new coordinate with finer control behind it. + * This packet differs from [CamMoveToCyclesV1] in that it will first + * move through a center coordinate before going towards the destination, + * creating a `)`-shape movement. An example image of this can be seen + * [here](https://media.z-kris.com/2024/04/cam%20move%20eased%20circular.png) + * + * @property centerXInBuildArea the center x coordinate within the build area, + * in range of 0 to 103 (inclusive). This marks the middle point between the + * camera movement through which the camera has to go. + * @property centerZInBuildArea the center z coordinate within the build area, + * in range of 0 to 103 (inclusive). This marks the middle point between the + * camera movement through which the camera has to go. + * @property destinationXInBuildArea the dest x coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property destinationZInBuildArea the dest z coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property height the height of the camera once it arrives at the destination + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property ignoreTerrain whether the camera moves along the terrain, + * moving up and down according to bumps in the terrain. + * If true, the camera will move in a straight line from the starting position + * towards the end position, ignoring any changes in the terrain. + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +@Suppress("DuplicatedCode") +public class CamMoveToArcV1 private constructor( + private val centerCoordInBuildArea: CoordInBuildArea, + private val destinationCoordInBuildArea: CoordInBuildArea, + private val _height: UShort, + private val _cycles: UShort, + public val ignoreTerrain: Boolean, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + centerXInBuildArea: Int, + centerZInBuildArea: Int, + destinationXInBuildArea: Int, + destinationZInBuildArea: Int, + height: Int, + cycles: Int, + ignoreTerrain: Boolean, + easing: Int, + ) : this( + CoordInBuildArea(centerXInBuildArea, centerZInBuildArea), + CoordInBuildArea(destinationXInBuildArea, destinationZInBuildArea), + height.toUShort(), + cycles.toUShort(), + ignoreTerrain, + easing.toUByte(), + ) + + public val centerXInBuildArea: Int + get() = centerCoordInBuildArea.xInBuildArea + public val centerZInBuildArea: Int + get() = centerCoordInBuildArea.zInBuildArea + public val destinationXInBuildArea: Int + get() = destinationCoordInBuildArea.xInBuildArea + public val destinationZInBuildArea: Int + get() = destinationCoordInBuildArea.zInBuildArea + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToArcV1 + + if (centerCoordInBuildArea != other.centerCoordInBuildArea) return false + if (destinationCoordInBuildArea != other.destinationCoordInBuildArea) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (ignoreTerrain != other.ignoreTerrain) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = centerCoordInBuildArea.hashCode() + result = 31 * result + destinationCoordInBuildArea.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + ignoreTerrain.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String = + "CamMoveToArcV1(" + + "centerXInBuildArea=$centerXInBuildArea, " + + "centerZInBuildArea=$centerZInBuildArea, " + + "destinationXInBuildArea=$destinationXInBuildArea, " + + "destinationZInBuildArea=$destinationZInBuildArea, " + + "height=$height, " + + "cycles=$cycles, " + + "ignoreTerrain=$ignoreTerrain, " + + "easing=$easing" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV2.kt new file mode 100644 index 000000000..30e5ab233 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToArcV2.kt @@ -0,0 +1,119 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Camera move to arc packet is used to move camera + * to a new coordinate with finer control behind it. + * This packet differs from [CamMoveToCyclesV2] in that it will first + * move through a center coordinate before going towards the destination, + * creating a `)`-shape movement. An example image of this can be seen + * [here](https://media.z-kris.com/2024/04/cam%20move%20eased%20circular.png) + * + * @property centerX the absolute x coordinate to move through. + * @property centerZ the absolute z coordinate to move through. + * @property destinationX the absolute x coordinate to move to. + * @property destinationZ the absolute z coordinate to move to. + * @property height the height of the camera once it arrives at the destination + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property ignoreTerrain whether the camera moves along the terrain, + * moving up and down according to bumps in the terrain. + * If true, the camera will move in a straight line from the starting position + * towards the end position, ignoring any changes in the terrain. + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +@Suppress("DuplicatedCode") +public class CamMoveToArcV2 private constructor( + private val _centerX: UShort, + private val _centerZ: UShort, + private val _destinationX: UShort, + private val _destinationZ: UShort, + private val _height: UShort, + private val _cycles: UShort, + public val ignoreTerrain: Boolean, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + centerX: Int, + centerZ: Int, + destinationX: Int, + destinationZ: Int, + height: Int, + cycles: Int, + ignoreTerrain: Boolean, + easing: Int, + ) : this( + centerX.toUShort(), + centerZ.toUShort(), + destinationX.toUShort(), + destinationZ.toUShort(), + height.toUShort(), + cycles.toUShort(), + ignoreTerrain, + easing.toUByte(), + ) + + public val centerX: Int + get() = _centerX.toInt() + public val centerZ: Int + get() = _centerZ.toInt() + public val destinationX: Int + get() = _destinationX.toInt() + public val destinationZ: Int + get() = _destinationZ.toInt() + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToArcV2 + + if (ignoreTerrain != other.ignoreTerrain) return false + if (_centerX != other._centerX) return false + if (_centerZ != other._centerZ) return false + if (_destinationX != other._destinationX) return false + if (_destinationZ != other._destinationZ) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = ignoreTerrain.hashCode() + result = 31 * result + _centerX.hashCode() + result = 31 * result + _centerZ.hashCode() + result = 31 * result + _destinationX.hashCode() + result = 31 * result + _destinationZ.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String { + return "CamMoveToArcV2(" + + "centerX=$centerX, " + + "centerZ=$centerZ, " + + "destinationX=$destinationX, " + + "destinationZ=$destinationZ, " + + "height=$height, " + + "cycles=$cycles, " + + "easing=$easing, " + + "ignoreTerrain=$ignoreTerrain" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV1.kt new file mode 100644 index 000000000..071b60c2a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV1.kt @@ -0,0 +1,94 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Camera move to cycles packet is used to move camera + * to a new coordinate with finer control behind it. + * @property destinationXInBuildArea the dest x coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property destinationZInBuildArea the dest z coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property height the height of the camera once it arrives at the destination + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property ignoreTerrain whether the camera moves along the terrain, + * moving up and down according to bumps in the terrain. + * If true, the camera will move in a straight line from the starting position + * towards the end position, ignoring any changes in the terrain. + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +@Suppress("DuplicatedCode") +public class CamMoveToCyclesV1 private constructor( + private val destinationCoordInBuildArea: CoordInBuildArea, + private val _height: UShort, + private val _cycles: UShort, + public val ignoreTerrain: Boolean, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + xInBuildArea: Int, + zInBuildArea: Int, + height: Int, + cycles: Int, + ignoreTerrain: Boolean, + easing: Int, + ) : this( + CoordInBuildArea(xInBuildArea, zInBuildArea), + height.toUShort(), + cycles.toUShort(), + ignoreTerrain, + easing.toUByte(), + ) + + public val destinationXInBuildArea: Int + get() = destinationCoordInBuildArea.xInBuildArea + public val destinationZInBuildArea: Int + get() = destinationCoordInBuildArea.zInBuildArea + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToCyclesV1 + + if (destinationCoordInBuildArea != other.destinationCoordInBuildArea) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (ignoreTerrain != other.ignoreTerrain) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = destinationCoordInBuildArea.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + ignoreTerrain.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String = + "CamMoveToCyclesV1(" + + "destinationXInBuildArea=$destinationXInBuildArea, " + + "destinationZInBuildArea=$destinationZInBuildArea, " + + "height=$height, " + + "cycles=$cycles, " + + "ignoreTerrain=$ignoreTerrain, " + + "easing=$easing" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV2.kt new file mode 100644 index 000000000..544fd2a06 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToCyclesV2.kt @@ -0,0 +1,96 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Camera move to cycles packet is used to move camera + * to a new coordinate with finer control behind it. + * @property x the absolute x coordinate to move to. + * @property z the absolute z coordinate to move to. + * @property height the height of the camera once it arrives at the destination + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property ignoreTerrain whether the camera moves along the terrain, + * moving up and down according to bumps in the terrain. + * If true, the camera will move in a straight line from the starting position + * towards the end position, ignoring any changes in the terrain. + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +@Suppress("DuplicatedCode") +public class CamMoveToCyclesV2 private constructor( + private val _x: UShort, + private val _z: UShort, + private val _height: UShort, + private val _cycles: UShort, + public val ignoreTerrain: Boolean, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + x: Int, + z: Int, + height: Int, + cycles: Int, + ignoreTerrain: Boolean, + easing: Int, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUShort(), + cycles.toUShort(), + ignoreTerrain, + easing.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val height: Int + get() = _height.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToCyclesV2 + + if (ignoreTerrain != other.ignoreTerrain) return false + if (_x != other._x) return false + if (_z != other._z) return false + if (_height != other._height) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = ignoreTerrain.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String { + return "CamMoveToCyclesV2(" + + "x=$x, " + + "z=$z, " + + "height=$height, " + + "cycles=$cycles, " + + "easing=$easing, " + + "ignoreTerrain=$ignoreTerrain" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV1.kt new file mode 100644 index 000000000..42c48ee3a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV1.kt @@ -0,0 +1,87 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam move to packet is used to move the position of the camera + * to a specific coordinate within the current build area. + * It is important to note that if this is sent together with + * a map reload, whether this packet comes before or after the + * map reload makes a difference - as the build area itself changes. + * + * @property destinationXInBuildArea the dest x coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property destinationZInBuildArea the dest z coordinate within the build area, + * in range of 0 to 103 (inclusive) + * @property height the height of the camera + * @property rate the constant speed at which the camera moves + * to the new coordinate + * @property rate2 the speed increase as the camera moves + * towards the end coordinate. + */ +public class CamMoveToV1 private constructor( + private val destinationCoordInBuildArea: CoordInBuildArea, + private val _height: UShort, + private val _rate: UByte, + private val _rate2: UByte, +) : OutgoingGameMessage { + public constructor( + xInBuildArea: Int, + zInBuildArea: Int, + height: Int, + rate: Int, + rate2: Int, + ) : this( + CoordInBuildArea(xInBuildArea, zInBuildArea), + height.toUShort(), + rate.toUByte(), + rate2.toUByte(), + ) + + public val destinationXInBuildArea: Int + get() = destinationCoordInBuildArea.xInBuildArea + public val destinationZInBuildArea: Int + get() = destinationCoordInBuildArea.zInBuildArea + public val height: Int + get() = _height.toInt() + public val rate: Int + get() = _rate.toInt() + public val rate2: Int + get() = _rate2.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToV1 + + if (destinationCoordInBuildArea != other.destinationCoordInBuildArea) return false + if (_height != other._height) return false + if (_rate != other._rate) return false + if (_rate2 != other._rate2) return false + + return true + } + + override fun hashCode(): Int { + var result = destinationCoordInBuildArea.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _rate.hashCode() + result = 31 * result + _rate2.hashCode() + return result + } + + override fun toString(): String = + "CamMoveToV1(" + + "destinationXInBuildArea=$destinationXInBuildArea, " + + "destinationZInBuildArea=$destinationZInBuildArea, " + + "height=$height, " + + "rate=$rate, " + + "rate2=$rate2" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV2.kt new file mode 100644 index 000000000..d0c71b79a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamMoveToV2.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam move to packet is used to move the position of the camera + * to a specific coordinate in the root world. + * + * @property x the absolute x coordinate to move to. + * @property z the absolute z coordinate to move to. + * @property height the height of the camera + * @property rate the constant speed at which the camera moves + * to the new coordinate + * @property rate2 the speed increase as the camera moves + * towards the end coordinate. + */ +public class CamMoveToV2 private constructor( + private val _x: UShort, + private val _z: UShort, + private val _height: UShort, + private val _rate: UByte, + private val _rate2: UByte, +) : OutgoingGameMessage { + public constructor( + x: Int, + z: Int, + height: Int, + rate: Int, + rate2: Int, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUShort(), + rate.toUByte(), + rate2.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val height: Int + get() = _height.toInt() + public val rate: Int + get() = _rate.toInt() + public val rate2: Int + get() = _rate2.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamMoveToV2 + + if (_x != other._x) return false + if (_z != other._z) return false + if (_height != other._height) return false + if (_rate != other._rate) return false + if (_rate2 != other._rate2) return false + + return true + } + + override fun hashCode(): Int { + var result = _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _rate.hashCode() + result = 31 * result + _rate2.hashCode() + return result + } + + override fun toString(): String { + return "CamMoveToV2(" + + "x=$x, " + + "z=$z, " + + "height=$height, " + + "rate=$rate, " + + "rate2=$rate2" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamReset.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamReset.kt new file mode 100644 index 000000000..82c333ace --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamReset.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam reset is used to clear out any camera shaking or + * any sort of movements that might've been previously set. + * Additionally, unlocks the camera if it has been locked in place. + */ +public data object CamReset : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateBy.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateBy.kt new file mode 100644 index 000000000..16475342c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateBy.kt @@ -0,0 +1,87 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam rotate by is used to make the camera look towards + * an angle relative to the current camera angle. + * One way to think of this packet is that it **adds** values to the + * x and y angles of the camera. + * + * @property pitch the additional angle to add to the x-axis of the camera. + * It's worth noting that the x angle of the camera ranges between 128 and + * 383 (inclusive), and the resulting value is coerced in that range. + * Negative values are also accepted. + * Additionally, there is currently a bug in the client that causes the + * third and the fifth least significant bits of the resulting angle to + * be discarded due to the code doing (cameraXAngle + [pitch] & 2027), + * which is further coerced into the 128-383 range. + * @property yaw the additional angle to add to the y-axis of the camera. + * Unlike the x-axis angle, this one ranges from 0 to 2047 (inclusive), + * and does not get coerced - instead it will just roll over (e.g. 2047 -> 0). + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +public class CamRotateBy private constructor( + private val _pitch: Short, + private val _yaw: Short, + private val _cycles: UShort, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + pitch: Int, + yaw: Int, + cycles: Int, + easing: Int, + ) : this( + pitch.toShort(), + yaw.toShort(), + cycles.toUShort(), + easing.toUByte(), + ) + + public val pitch: Int + get() = _pitch.toInt() + public val yaw: Int + get() = _yaw.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamRotateBy + + if (_pitch != other._pitch) return false + if (_yaw != other._yaw) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = _pitch.toInt() + result = 31 * result + _yaw + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String = + "CamRotateBy(" + + "pitch=$pitch, " + + "yaw=$yaw, " + + "cycles=$cycles, " + + "easing=$easing" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateTo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateTo.kt new file mode 100644 index 000000000..0f8ec4c1a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamRotateTo.kt @@ -0,0 +1,84 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.camera.util.CameraEaseFunction +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam rotate to is used to make the camera look towards + * an angle relative to the current camera angle. + * One way to think of this packet is that it **adds** values to the + * x and y angles of the camera. + * + * @property pitch the x angle of the camera to set to. + * Note that the angle is coerced into a range of 128..383, + * and incorrectly excludes the third and fifth least significant bits + * before doing so (by doing [pitch] & 2027, rather than 2047). + * @property yaw the x angle of the camera to set to. + * Note that the angle incorrectly excludes the third and fifth least significant bits + * (by doing [pitch] & 2027, rather than 2047). + * @property cycles the duration of the movement in client cycles (20ms/cc) + * @property easing the camera easing function, allowing for finer + * control over the way it moves from the start coordinate to the end. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class CamRotateTo private constructor( + private val _pitch: Short, + private val _yaw: Short, + private val _cycles: UShort, + private val _easing: UByte, +) : OutgoingGameMessage { + public constructor( + pitch: Int, + yaw: Int, + cycles: Int, + easing: Int, + ) : this( + pitch.toShort(), + yaw.toShort(), + cycles.toUShort(), + easing.toUByte(), + ) + + public val pitch: Int + get() = _pitch.toInt() + public val yaw: Int + get() = _yaw.toInt() + public val cycles: Int + get() = _cycles.toInt() + public val easing: CameraEaseFunction + get() = CameraEaseFunction[_easing.toInt()] + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamRotateTo + + if (_pitch != other._pitch) return false + if (_yaw != other._yaw) return false + if (_cycles != other._cycles) return false + if (_easing != other._easing) return false + + return true + } + + override fun hashCode(): Int { + var result = _pitch.toInt() + result = 31 * result + _yaw + result = 31 * result + _cycles.hashCode() + result = 31 * result + _easing.hashCode() + return result + } + + override fun toString(): String = + "CamRotateTo(" + + "pitch=$pitch, " + + "yaw=$yaw, " + + "cycles=$cycles, " + + "easing=$easing" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamShake.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamShake.kt new file mode 100644 index 000000000..c674d97be --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamShake.kt @@ -0,0 +1,97 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam shake packet is used to make the camera shake around. + * It is worth noting that multiple different types of shakes + * can be executed simultaneously, making the camera more and + * more volatile as a result. + * + * The properties of this class are in the exact order as + * the client reads them, which is consistent across revisions! + * + * Camera movements table: + * ``` + * | Id | Type | Observed Movement | + * |----|:-------:|:----------------------:| + * | 0 | X-axis | Left and right | + * | 1 | Y-axis | Up and down | + * | 2 | Z-axis | Forwards and backwards | + * | 3 | Y-angle | Panning left and right | + * | 4 | X-angle | Panning up and down | + * ``` + * + * @property axis the type of the shake (see table above) + * @property random the amount of randomness involved. + * The client will generate a random double from 0.0 to 1.0 + * and multiply it with the [random] as part of the shaking. + * This property is called 'shakeIntensity' in the event inspector. + * @property amplitude the amount of randomness generated by the + * sine. Unlike [random], this is multiplied against the + * [rate]. + * This property is called 'movementIntensity' in the event inspector. + * @property rate the sine frequency. + * This property is called 'speed' in the event inspector. + */ +public class CamShake private constructor( + private val _axis: UByte, + private val _random: UByte, + private val _amplitude: UByte, + private val _rate: UByte, +) : OutgoingGameMessage { + public constructor( + axis: Int, + random: Int, + amplitude: Int, + rate: Int, + ) : this( + axis.toUByte(), + random.toUByte(), + amplitude.toUByte(), + rate.toUByte(), + ) + + public val axis: Int + get() = _axis.toInt() + public val random: Int + get() = _random.toInt() + public val amplitude: Int + get() = _amplitude.toInt() + public val rate: Int + get() = _rate.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamShake + + if (_axis != other._axis) return false + if (_random != other._random) return false + if (_amplitude != other._amplitude) return false + if (_rate != other._rate) return false + + return true + } + + override fun hashCode(): Int { + var result = _axis.hashCode() + result = 31 * result + _random.hashCode() + result = 31 * result + _amplitude.hashCode() + result = 31 * result + _rate.hashCode() + return result + } + + override fun toString(): String = + "CamShake(" + + "axis=$axis, " + + "random=$random, " + + "amplitude=$amplitude, " + + "rate=$rate" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamSmoothReset.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamSmoothReset.kt new file mode 100644 index 000000000..dae1c988f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamSmoothReset.kt @@ -0,0 +1,78 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Cam smooth reset is used to smoothly reset camera back to the + * state where the user is in control, instead of it happening + * instantaneously. + * + * Note that the properties of this packet are unused in the Java client. + * + * **WARNING:** The client code __requires__ that the camera is in + * a locked state for this packet's code to be executed in **Java**. + * If the camera isn't in a locked state, an error condition is hit + * at the bottom of the function and the player will be kicked out of + * the game! + */ +public class CamSmoothReset private constructor( + private val _cameraMoveConstantSpeed: UByte, + private val _cameraMoveProportionalSpeed: UByte, + private val _cameraLookConstantSpeed: UByte, + private val _cameraLookProportionalSpeed: UByte, +) : OutgoingGameMessage { + public constructor( + cameraMoveConstantSpeed: Int, + cameraMoveProportionalSpeed: Int, + cameraLookConstantSpeed: Int, + cameraLookProportionalSpeed: Int, + ) : this( + cameraMoveConstantSpeed.toUByte(), + cameraMoveProportionalSpeed.toUByte(), + cameraLookConstantSpeed.toUByte(), + cameraLookProportionalSpeed.toUByte(), + ) + + public val cameraMoveConstantSpeed: Int + get() = _cameraMoveConstantSpeed.toInt() + public val cameraMoveProportionalSpeed: Int + get() = _cameraMoveProportionalSpeed.toInt() + public val cameraLookConstantSpeed: Int + get() = _cameraLookConstantSpeed.toInt() + public val cameraLookProportionalSpeed: Int + get() = _cameraLookProportionalSpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamSmoothReset + + if (_cameraMoveConstantSpeed != other._cameraMoveConstantSpeed) return false + if (_cameraMoveProportionalSpeed != other._cameraMoveProportionalSpeed) return false + if (_cameraLookConstantSpeed != other._cameraLookConstantSpeed) return false + if (_cameraLookProportionalSpeed != other._cameraLookProportionalSpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = _cameraMoveConstantSpeed.hashCode() + result = 31 * result + _cameraMoveProportionalSpeed.hashCode() + result = 31 * result + _cameraLookConstantSpeed.hashCode() + result = 31 * result + _cameraLookProportionalSpeed.hashCode() + return result + } + + override fun toString(): String = + "CamSmoothReset(" + + "cameraMoveConstantSpeed=$cameraMoveConstantSpeed, " + + "cameraMoveProportionalSpeed=$cameraMoveProportionalSpeed, " + + "cameraLookConstantSpeed=$cameraLookConstantSpeed, " + + "cameraLookProportionalSpeed=$cameraLookProportionalSpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamTargetV3.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamTargetV3.kt new file mode 100644 index 000000000..cc99cb3f2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CamTargetV3.kt @@ -0,0 +1,162 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Camera target packet is used to attach to camera on another entity in the scene. + * If the entity by the specified index cannot be found in the client, the camera + * will always be focused back on the local player. + * Furthermore, depth buffering (z-buffer) will be enabled if the [WorldEntityTarget] type + * is used. Other types will use the traditional priority system. + * @property type the camera target type to focus on. + */ +public class CamTargetV3( + public val type: CamTargetType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CamTargetV3 + + return type == other.type + } + + override fun hashCode(): Int = type.hashCode() + + override fun toString(): String = "CamTargetV3(type=$type)" + + /** + * A sealed interface for various camera target types. + */ + public sealed interface CamTargetType + + /** + * Camera target type for players. This will focus the camera on a player on a specific world entity. + * If the player by the specified [targetIndex] cannot be found, the camera will be set back on + * local player. + * @property targetIndex the index of the player who to set the camera on. + */ + public class PlayerCamTarget( + public val worldEntityIndex: Int, + public val targetIndex: Int, + ) : CamTargetType { + init { + require(worldEntityIndex == -1 || worldEntityIndex in 0..<2048) { + "World entity index must be -1, or in range of 0..<2048" + } + require(targetIndex in 0..<2048) { + "Index must be in range of 0..<2048" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerCamTarget + + if (worldEntityIndex != other.worldEntityIndex) return false + if (targetIndex != other.targetIndex) return false + + return true + } + + override fun hashCode(): Int { + var result = worldEntityIndex + result = 31 * result + targetIndex + return result + } + + override fun toString(): String { + return "PlayerCamTarget(" + + "worldEntityIndex=$worldEntityIndex, " + + "targetIndex=$targetIndex" + + ")" + } + } + + /** + * Camera target type for NPCs. This will focus the camera on a specific NPC on a specific worldentity. + * If the NPC by the specified [targetIndex] cannot be found, the camera will be set back on + * local player. + * @property targetIndex the index of the NPC who to set the camera on. + */ + public class NpcCamTarget( + public val worldEntityIndex: Int, + public val targetIndex: Int, + ) : CamTargetType { + init { + require(worldEntityIndex == -1 || worldEntityIndex in 0..<2048) { + "World entity index must be -1, or in range of 0..<2048" + } + require(targetIndex in 0..<65536) { + "Index must be in range of 0..<65536" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcCamTarget + + if (worldEntityIndex != other.worldEntityIndex) return false + if (targetIndex != other.targetIndex) return false + + return true + } + + override fun hashCode(): Int { + var result = worldEntityIndex + result = 31 * result + targetIndex + return result + } + + override fun toString(): String { + return "NpcCamTarget(" + + "worldEntityIndex=$worldEntityIndex, " + + "targetIndex=$targetIndex" + + ")" + } + } + + /** + * Camera target type for world entities. This will focus the camera on a specific world entity. + * If the world entity by the specified [targetIndex] cannot be found, the camera will be set back on + * local player. + * Additionally, depth buffering (z-buffer) will be enabled when this type of camera target is used. + * @property targetIndex the index of the world entity who to set the camera on. + */ + public class WorldEntityTarget( + public val targetIndex: Int, + ) : CamTargetType { + init { + require(targetIndex in 0..<2048) { + "World entity target index must be in range of 0..<2048" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WorldEntityTarget + + return targetIndex == other.targetIndex + } + + override fun hashCode(): Int { + return targetIndex + } + + override fun toString(): String { + return "WorldEntityTarget(targetIndex=$targetIndex)" + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CameraTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CameraTypealiases.kt new file mode 100644 index 000000000..2a9fe7574 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/CameraTypealiases.kt @@ -0,0 +1,45 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.camera + +@Deprecated( + message = "Deprecated. Use CamTargetV3.", + replaceWith = ReplaceWith("CamTargetV3"), +) +public typealias CamTarget = CamTargetV3 + +@Deprecated( + message = "Deprecated. Use CamTargetV3.", + replaceWith = ReplaceWith("CamTargetV3"), +) +public typealias CamTargetV2 = CamTargetV3 + +@Deprecated( + message = "Deprecated. Use CamMoveToV2.", + replaceWith = ReplaceWith("CamMoveToV2"), +) +public typealias CamMoveTo = CamMoveToV1 + +@Deprecated( + message = "Deprecated. Use CamLookAtV2.", + replaceWith = ReplaceWith("CamLookAtV2"), +) +public typealias CamLookAt = CamLookAtV1 + +@Deprecated( + message = "Deprecated. Use CamMoveToCyclesV2.", + replaceWith = ReplaceWith("CamMoveToCyclesV2"), +) +public typealias CamMoveToCycles = CamMoveToCyclesV1 + +@Deprecated( + message = "Deprecated. Use CamLookAtEasedCoordV2.", + replaceWith = ReplaceWith("CamLookAtEasedCoordV2"), +) +public typealias CamLookAtEasedCoord = CamLookAtEasedCoordV1 + +@Deprecated( + message = "Deprecated. Use CamMoveToArcV2.", + replaceWith = ReplaceWith("CamMoveToArcV2"), +) +public typealias CamMoveToArc = CamMoveToArcV1 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/OculusSync.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/OculusSync.kt new file mode 100644 index 000000000..c87569fd5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/OculusSync.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.camera + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Oculus sync is used to re-synchronize the orb of oculus + * camera to the local player in the client, if the value + * does not match up with the client's value. + * The client initializes this property as zero. + * @property value the synchronization value, if the client's + * value is different, oculus camera is moved to the client's local player. + * Additionally, this value is sent by the client in the + * [net.rsprot.protocol.game.incoming.misc.user.Teleport] packet whenever + * the oculus causes the player to teleport. + */ +public class OculusSync( + public val value: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OculusSync + + return value == other.value + } + + override fun hashCode(): Int = value + + override fun toString(): String = "OculusSync(value=$value)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/util/CameraEaseFunction.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/util/CameraEaseFunction.kt new file mode 100644 index 000000000..750e836c6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/camera/util/CameraEaseFunction.kt @@ -0,0 +1,59 @@ +package net.rsprot.protocol.game.outgoing.camera.util + +import kotlin.jvm.Throws + +/** + * Camera functions for eased movement. + * These functions are used together with various 'eased' camera + * packets to alter how the camera movement happens between the + * coordinates provided. + * + * @property id the respective id of the camera function, + * as expected by the client. + */ +public enum class CameraEaseFunction( + public val id: Int, +) { + LINEAR(0), + EASE_IN_SINE(1), + EASE_OUT_SINE(2), + EASE_IN_OUT_SINE(3), + EASE_IN_QUAD(4), + EASE_OUT_QUAD(5), + EASE_IN_OUT_QUAD(6), + EASE_IN_CUBIC(7), + EASE_OUT_CUBIC(8), + EASE_IN_OUT_CUBIC(9), + EASE_IN_QUART(10), + EASE_OUT_QUART(11), + EASE_IN_OUT_QUART(12), + EASE_IN_QUINT(13), + EASE_OUT_QUINT(14), + EASE_IN_OUT_QUINT(15), + EASE_IN_EXPO(16), + EASE_OUT_EXPO(17), + EASE_IN_OUT_EXPO(18), + EASE_IN_CIRC(19), + EASE_OUT_CIRC(20), + EASE_IN_OUT_CIRC(21), + EASE_IN_BACK(22), + EASE_OUT_BACK(23), + EASE_IN_OUT_BACK(24), + EASE_IN_ELASTIC(25), + EASE_OUT_ELASTIC(26), + EASE_IN_OUT_ELASTIC(27), + ; + + public companion object { + /** + * Gets the camera easing function based on the [id] provided. + * @throws IndexOutOfBoundsException if the id is below 0 or above 27 + * @return camera ease function + */ + @Throws(IndexOutOfBoundsException::class) + public operator fun get(id: Int): CameraEaseFunction { + // Relying on ordinal here as ordinal aligns with the id values. + return entries[id] + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelDelta.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelDelta.kt new file mode 100644 index 000000000..07b6aedd5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelDelta.kt @@ -0,0 +1,346 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Clan channel delta is a packet used to transmit partial updates + * to an existing clan channel. This prevents sending a full update for everything + * as that can get rather wasteful. + * @property clanType the type of the clan the player is in + * @property clanHash the 64-bit hash of the clan + * @property updateNum the update counter/timestamp for the clan. + * The exact behaviours behind this are not known, but the value appears to be + * an epoch time millis, with each minor change resulting in the value incrementing + * by +1; e.g. each member joining seems to increment the value by 1. + * @property events the list of channel delta events to perform in this update + */ +public class ClanChannelDelta private constructor( + private val _clanType: Byte, + public val clanHash: Long, + public val updateNum: Long, + public val events: List, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + key: Long, + updateNum: Long, + events: List, + ) : this( + clanType.toByte(), + key, + updateNum, + events, + ) + + public val clanType: Int + get() = _clanType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + // Always assume the biggest event size, which is 29 bytes + return Byte.SIZE_BYTES + + Long.SIZE_BYTES + + Long.SIZE_BYTES + + Byte.SIZE_BYTES + + (29 * events.size) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanChannelDelta + + if (_clanType != other._clanType) return false + if (clanHash != other.clanHash) return false + if (updateNum != other.updateNum) return false + if (events != other.events) return false + + return true + } + + override fun hashCode(): Int { + var result = _clanType.toInt() + result = 31 * result + clanHash.hashCode() + result = 31 * result + updateNum.hashCode() + result = 31 * result + events.hashCode() + return result + } + + override fun toString(): String = + "ClanChannelDelta(" + + "clanType=$clanType, " + + "clanHash=$clanHash, " + + "updateNum=$updateNum, " + + "events=$events" + + ")" + + public sealed interface Event + + /** + * Clan channel delta adduser event is used to add a new user + * into the clan. + * @property name the name of the player to add to the clan + * @property world the id of the world in which the player resides + * @property rank the rank of the player within the clan + */ + public class AddUserEvent private constructor( + public val name: String, + private val _world: UShort, + private val _rank: Byte, + ) : Event { + public constructor( + name: String, + world: Int, + rank: Int, + ) : this( + name, + world.toUShort(), + rank.toByte(), + ) + + public val world: Int + get() = _world.toInt() + public val rank: Int + get() = _rank.toInt() + + override fun toString(): String = + "AddUserEvent(" + + "name='$name', " + + "world=$world, " + + "rank=$rank" + + ")" + } + + /** + * Clan channel delta update base settings event is used to modify the base + * settings of a clan. + * @property clanName the clan name to set + * @property talkRank the minimum rank needed to talk + * @property kickRank the minimum rank needed to kick other members + */ + public class UpdateBaseSettingsEvent private constructor( + public val clanName: String?, + private val _talkRank: Byte, + private val _kickRank: Byte, + ) : Event { + public constructor() : this( + null, + 0, + 0, + ) + + public constructor( + clanName: String, + talkRank: Int, + kickRank: Int, + ) : this( + clanName, + talkRank.toByte(), + kickRank.toByte(), + ) + + public val talkRank: Int + get() = _talkRank.toInt() + public val kickRank: Int + get() = _kickRank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateBaseSettingsEvent + + if (clanName != other.clanName) return false + if (_talkRank != other._talkRank) return false + if (_kickRank != other._kickRank) return false + + return true + } + + override fun hashCode(): Int { + var result = clanName?.hashCode() ?: 0 + result = 31 * result + _talkRank + result = 31 * result + _kickRank + return result + } + + override fun toString(): String = + "UpdateBaseSettingsEvent(" + + "clanName=$clanName, " + + "talkRank=$talkRank, " + + "kickRank=$kickRank" + + ")" + } + + /** + * Clan channel delta delete user event is used to delete an existing + * member from the clan. + * @property index the index of the player within the clan. + * Note that this index is the index within this clan, and not a global + * index of the player. + */ + public class DeleteUserEvent private constructor( + private val _index: UShort, + ) : Event { + public constructor( + index: Int, + ) : this( + index.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DeleteUserEvent + + return _index == other._index + } + + override fun hashCode(): Int = _index.hashCode() + + override fun toString(): String = "DeleteUserEvent(index=$index)" + } + + /** + * Clan channel delta update user details event is used to modify + * the details of a user in the clan. + * @property index the index of the player whom to update within the clan. + * Note that this is the index within the clan's list of members and not + * the world-global indexed player list. + * @property name the new name of this player within the clan + * @property rank the new rank of this player within the clan + * @property world the new world of this player within the clan + */ + public class UpdateUserDetailsEvent private constructor( + private val _index: UShort, + public val name: String, + private val _rank: Byte, + private val _world: UShort, + ) : Event { + public constructor( + index: Int, + name: String, + rank: Int, + world: Int, + ) : this( + index.toUShort(), + name, + rank.toByte(), + world.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val rank: Int + get() = _rank.toInt() + public val world: Int + get() = _world.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateUserDetailsEvent + + if (_index != other._index) return false + if (name != other.name) return false + if (_rank != other._rank) return false + if (_world != other._world) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _index.hashCode() + result = 31 * result + _rank + result = 31 * result + _world.hashCode() + return result + } + + override fun toString(): String = + "UpdateUserDetailsEvent(" + + "index=$index, " + + "name='$name', " + + "rank=$rank, " + + "world=$world" + + ")" + } + + /** + * Clan channel delta update user details v2 event is used to modify + * the details of a user in the clan. + * Note that this class is identical to the [UpdateUserDetailsEvent], + * with the only exception being that more bandwidth is used to transmit this update, + * as there are multiple unused properties being sent on-top. + * @property index the index of the player whom to update within the clan. + * Note that this is the index within the clan's list of members and not + * the world-global indexed player list. + * @property name the new name of this player within the clan + * @property rank the new rank of this player within the clan + * @property world the new world of this player within the clan + */ + public class UpdateUserDetailsV2Event private constructor( + private val _index: UShort, + public val name: String, + private val _rank: Byte, + private val _world: UShort, + ) : Event { + public constructor( + index: Int, + name: String, + rank: Int, + world: Int, + ) : this( + index.toUShort(), + name, + rank.toByte(), + world.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val rank: Int + get() = _rank.toInt() + public val world: Int + get() = _world.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateUserDetailsV2Event + + if (_index != other._index) return false + if (name != other.name) return false + if (_rank != other._rank) return false + if (_world != other._world) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _index.hashCode() + result = 31 * result + _rank + result = 31 * result + _world.hashCode() + return result + } + + override fun toString(): String = + "UpdateUserDetailsV2Event(" + + "index=$index, " + + "name='$name', " + + "rank=$rank, " + + "world=$world" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelFull.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelFull.kt new file mode 100644 index 000000000..b04aa88ff --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanChannelFull.kt @@ -0,0 +1,270 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Clan channel full packets are used to update + * the state of a clan upon first joining it, or when the player is leaving it. + * @property clanType the type of the clan the player is joining or leaving, + * such as guest or normal. + * @property update the type of update to perform, either [JoinUpdate] + * or [LeaveUpdate]. + */ +public class ClanChannelFull private constructor( + private val _clanType: Byte, + public val update: Update, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + update: Update, + ) : this( + clanType.toByte(), + update, + ) + + public val clanType: Int + get() = _clanType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return when (update) { + is JoinUpdate -> { + val memberPayloadSize = + update.members.size * + (13 + Byte.SIZE_BYTES + Short.SIZE_BYTES + Byte.SIZE_BYTES) + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Long.SIZE_BYTES + + Long.SIZE_BYTES + + estimateTextSize(update.clanName) + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Short.SIZE_BYTES + + memberPayloadSize + } + LeaveUpdate -> Byte.SIZE_BYTES + } + } + + override fun toString(): String = + "ClanChannelFull(" + + "update=$update, " + + "clanType=$clanType" + + ")" + + public sealed interface Update + + /** + * Clan channel full join update implies the user is joining + * a new clan. + * @property useBase37Names whether to send the names of players + * in a base-37 encoding. In OldSchool RuneScape, this option is unused. + * @property useDisplayNames whether to use display names for encoding. + * In OldSchool RuneScape, this is always the case and cannot be opted out of. + * @property hasVersion whether a custom version id is provided. + * It is unclear what the purpose behind this is, as the values are discarded. + * @property version the version id, defaulting to 2 in OldSchool RuneScape. + * @property clanHash the 64-bit hash of the clan + * @property updateNum the update counter/timestamp for the clan. + * The exact behaviours behind this are not known, but the value appears to be + * an epoch time millis, with each minor change resulting in the value incrementing + * by +1; e.g. each member joining seems to increment the value by 1. + * @property clanName the name of the clan + * @property discardedBoolean currently unknown as the client discards this value + * @property kickRank the minimum rank needed to kick other players from the clan + * @property talkRank the minimum rank needed to talk in the clan + * @property members the list of members within this clan. + */ + public class JoinUpdate private constructor( + private val _flags: UByte, + private val _version: UByte, + public val clanHash: Long, + public val updateNum: Long, + public val clanName: String, + public val discardedBoolean: Boolean, + private val _kickRank: Byte, + private val _talkRank: Byte, + public val members: List, + ) : Update { + public constructor( + clanHash: Long, + updateNum: Long, + clanName: String, + discardedBoolean: Boolean, + kickRank: Int, + talkRank: Int, + members: List, + version: Int = DEFAULT_OLDSCHOOL_VERSION, + base37Names: Boolean = false, + ) : this( + ( + FLAG_USE_DISPLAY_NAMES + .or(if (base37Names) FLAG_USE_BASE_37_NAMES else 0) + .or(if (version != DEFAULT_OLDSCHOOL_VERSION) FLAG_HAS_VERSION else 0) + ).toUByte(), + version.toUByte(), + clanHash, + updateNum, + clanName, + discardedBoolean, + kickRank.toByte(), + talkRank.toByte(), + members, + ) + + public val useBase37Names: Boolean + get() = _flags.toInt() and FLAG_USE_BASE_37_NAMES != 0 + public val useDisplayNames: Boolean + get() = _flags.toInt() and FLAG_USE_DISPLAY_NAMES != 0 + public val hasVersion: Boolean + get() = _flags.toInt() and FLAG_HAS_VERSION != 0 + public val version: Int + get() = _version.toInt() + public val flags: Int + get() = _flags.toInt() + public val kickRank: Int + get() = _kickRank.toInt() + public val talkRank: Int + get() = _talkRank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as JoinUpdate + + if (_flags != other._flags) return false + if (_version != other._version) return false + if (clanHash != other.clanHash) return false + if (updateNum != other.updateNum) return false + if (clanName != other.clanName) return false + if (discardedBoolean != other.discardedBoolean) return false + if (_kickRank != other._kickRank) return false + if (_talkRank != other._talkRank) return false + if (members != other.members) return false + + return true + } + + override fun hashCode(): Int { + var result = _flags.toInt() + result = 31 * result + _version.hashCode() + result = 31 * result + clanHash.hashCode() + result = 31 * result + updateNum.hashCode() + result = 31 * result + clanName.hashCode() + result = 31 * result + discardedBoolean.hashCode() + result = 31 * result + _kickRank + result = 31 * result + _talkRank + result = 31 * result + members.hashCode() + return result + } + + override fun toString(): String = + "JoinUpdate(" + + "useBase37Names=$useBase37Names, " + + "useDisplayNames=$useDisplayNames, " + + "hasVersion=$hasVersion, " + + "version=$version, " + + "key=$clanHash, " + + "updateNum=$updateNum, " + + "clanName='$clanName', " + + "discardedBoolean=$discardedBoolean, " + + "kickRank=$kickRank, " + + "talkRank=$talkRank, " + + "members=$members" + + ")" + } + + /** + * Clan channel full leave update implies the user is leaving an existing + * clan of theirs. + */ + public data object LeaveUpdate : Update + + /** + * Clan member classes are used to wrap all the properties shown in the clan + * interface about each player in the clan. + * @property name the display name of the clan member + * @property rank the rank of the clan member in the clan, + * for guest members, the rank is set to -1 + * @property world the world in which the player resides + * @property discardedBoolean unknown boolean (not used by the client) + */ + public class ClanMember private constructor( + public val name: String, + private val _rank: Byte, + private val _world: UShort, + public val discardedBoolean: Boolean, + ) { + public constructor( + name: String, + rank: Int, + world: Int, + discardedBoolean: Boolean, + ) : this( + name, + rank.toByte(), + world.toUShort(), + discardedBoolean, + ) + + public constructor( + name: String, + rank: Int, + world: Int, + ) : this( + name, + rank.toByte(), + world.toUShort(), + false, + ) + + public val rank: Int + get() = _rank.toInt() + public val world: Int + get() = _world.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanMember + + if (name != other.name) return false + if (_rank != other._rank) return false + if (_world != other._world) return false + if (discardedBoolean != other.discardedBoolean) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _rank + result = 31 * result + _world.hashCode() + result = 31 * result + discardedBoolean.hashCode() + return result + } + + override fun toString(): String = + "ClanMember(" + + "name='$name', " + + "rank=$rank, " + + "world=$world, " + + "discardedBoolean=$discardedBoolean" + + ")" + } + + public companion object { + public const val FLAG_USE_BASE_37_NAMES: Int = 0x1 + public const val FLAG_USE_DISPLAY_NAMES: Int = 0x2 + public const val FLAG_HAS_VERSION: Int = 0x4 + public const val DEFAULT_OLDSCHOOL_VERSION: Int = 2 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsDelta.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsDelta.kt new file mode 100644 index 000000000..fb8996904 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsDelta.kt @@ -0,0 +1,709 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Clan settings delta updates are used to modify a sub-set of this clan's settings. + * @property clanType the type of the clan to modify, e.g. guest or normal, + * @property owner the hash of the owner. + * As the value of this property is never assigned in the client, but it is compared, + * this property should always be assigned the value 0. + * @property updateNum the number of updates this clan's settings has had. + * If the value does not match up, the client will throw an exception! + */ +public class ClanSettingsDelta private constructor( + private val _clanType: Byte, + public val owner: Long, + public val updateNum: Int, + public val updates: List, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + owner: Long, + updateNum: Int, + updates: List, + ) : this( + clanType.toByte(), + owner, + updateNum, + updates, + ) + + public val clanType: Int + get() = _clanType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + // Assume worst case update which is 24 bytes each + return Byte.SIZE_BYTES + + Long.SIZE_BYTES + + Int.SIZE_BYTES + + (updates.size * 24) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ClanSettingsDelta + + if (_clanType != other._clanType) return false + if (owner != other.owner) return false + if (updateNum != other.updateNum) return false + if (updates != other.updates) return false + + return true + } + + override fun hashCode(): Int { + var result = _clanType.toInt() + result = 31 * result + owner.hashCode() + result = 31 * result + updateNum + return result + } + + override fun toString(): String = + "ClanSettingsDelta(" + + "clanType=$clanType, " + + "owner=$owner, " + + "updateNum=$updateNum, " + + "updates=$updates" + + ")" + + public sealed interface Update + + /** + * Add banned updates are used to add a member to the banned members list. + * @property hash the hash of the member, or -1 if this clan does not use hashes. + * @property name the name of the member. + */ + public class AddBannedUpdate( + public val hash: Long, + public val name: String?, + ) : Update { + /** + * A secondary constructor for when the clan does not support hashes. + */ + public constructor( + name: String, + ) : this( + -1, + name, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddBannedUpdate + + if (hash != other.hash) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AddBannedUpdate(" + + "hash=$hash, " + + "name=$name" + + ")" + } + + /** + * Older add-member update for clans. + * @property hash the hash of the member, or -1 if this clan does not use hashes. + * @property name the name of the member. + */ + public class AddMemberV1Update( + public val hash: Long, + public val name: String?, + ) : Update { + /** + * A secondary constructor for when the clan does not support hashes. + */ + public constructor( + name: String, + ) : this( + -1, + name, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddMemberV1Update + + if (hash != other.hash) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AddMemberV1Update(" + + "hash=$hash, " + + "name=$name" + + ")" + } + + /** + * Newer add-member update for clans. + * @property hash the hash of the member, or -1 if this clan does not use hashes. + * @property name the name of the member. + * @property joinRuneDay the rune day when this user joined the clan + */ + public class AddMemberV2Update private constructor( + public val hash: Long, + public val name: String?, + private val _joinRuneDay: UShort, + ) : Update { + public constructor( + hash: Long, + name: String?, + joinRuneDay: Int, + ) : this( + hash, + name, + joinRuneDay.toUShort(), + ) + + public constructor( + name: String?, + joinRuneDay: Int, + ) : this( + -1, + name, + joinRuneDay.toUShort(), + ) + + public val joinRuneDay: Int + get() = _joinRuneDay.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddMemberV2Update + + if (hash != other.hash) return false + if (name != other.name) return false + if (_joinRuneDay != other._joinRuneDay) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + _joinRuneDay.hashCode() + return result + } + + override fun toString(): String = + "AddMemberV2Update(" + + "hash=$hash, " + + "name=$name, " + + "joinRuneDay=$joinRuneDay" + + ")" + } + + /** + * Base settings updates are used to manage global clan settings, + * such as privileges to use various aspects of this clan. + * @property allowUnaffined whether guest members are allowed to join this clan + * @property talkRank the minimum rank needed to talk within this clan + * @property kickRank the minimum rank needed to kick other members in this clan + * @property lootshareRank the minimum rank needed to toggle lootshare, unused in OldSchool + * @property coinshareRank the minimum rank needed to toggle coinshare, unused in OldSchool + */ + public class BaseSettingsUpdate private constructor( + public val allowUnaffined: Boolean, + private val _talkRank: Byte, + private val _kickRank: Byte, + private val _lootshareRank: Byte, + private val _coinshareRank: Byte, + ) : Update { + public constructor( + allowUnaffined: Boolean, + talkRank: Int, + kickRank: Int, + lootshareRank: Int, + coinshareRank: Int, + ) : this( + allowUnaffined, + talkRank.toByte(), + kickRank.toByte(), + lootshareRank.toByte(), + coinshareRank.toByte(), + ) + + public val talkRank: Int + get() = _talkRank.toInt() + public val kickRank: Int + get() = _kickRank.toInt() + public val lootshareRank: Int + get() = _lootshareRank.toInt() + public val coinshareRank: Int + get() = _coinshareRank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BaseSettingsUpdate + + if (allowUnaffined != other.allowUnaffined) return false + if (_talkRank != other._talkRank) return false + if (_kickRank != other._kickRank) return false + if (_lootshareRank != other._lootshareRank) return false + if (_coinshareRank != other._coinshareRank) return false + + return true + } + + override fun hashCode(): Int { + var result = allowUnaffined.hashCode() + result = 31 * result + _talkRank + result = 31 * result + _kickRank + result = 31 * result + _lootshareRank + result = 31 * result + _coinshareRank + return result + } + + override fun toString(): String = + "BaseSettingsUpdate(" + + "allowUnaffined=$allowUnaffined, " + + "talkRank=$talkRank, " + + "kickRank=$kickRank, " + + "lootshareRank=$lootshareRank, " + + "coinshareRank=$coinshareRank" + + ")" + } + + /** + * Delete banned member updates are used to remove existing banned members + * from the list of banned users. + * @property index the index of the user in the banned members list. + */ + public class DeleteBannedUpdate( + public val index: Int, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DeleteBannedUpdate + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "DeleteBannedUpdate(index=$index)" + } + + /** + * Delete member updates are used to remove members from this clan. + * @property index the index of this member within the clan's member list. + */ + public class DeleteMemberUpdate( + public val index: Int, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DeleteMemberUpdate + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "DeleteMemberUpdate(index=$index)" + } + + /** + * Set member rank update is used to modify a given clan member's privileges + * within the clan. + * @property index the index of this member within the clan's member list. + * @property rank the new rank to assign to that member. + */ + public class SetMemberRankUpdate private constructor( + private val _index: UShort, + private val _rank: Byte, + ) : Update { + public constructor( + index: Int, + rank: Int, + ) : this( + index.toUShort(), + rank.toByte(), + ) + + public val index: Int + get() = _index.toInt() + public val rank: Int + get() = _rank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetMemberRankUpdate + + if (_index != other._index) return false + if (_rank != other._rank) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _rank + return result + } + + override fun toString(): String = + "SetMemberRankUpdate(" + + "index=$index, " + + "rank=$rank" + + ")" + } + + /** + * Set member extra info is used to modify extra info about a member in the clan, + * by modifying the provided bit range of the 32-bit integer that each + * member has. + * @property index the index of this member in the clan's members list. + * @property value the value to assign to the provided bit range + * @property startBit the start bit of the bit range to update + * @property endBit the end bit of the bit range to update + */ + public class SetMemberExtraInfoUpdate private constructor( + private val _index: UShort, + public val value: Int, + private val _startBit: UByte, + private val _endBit: UByte, + ) : Update { + public constructor( + index: Int, + value: Int, + startBit: Int, + endBit: Int, + ) : this( + index.toUShort(), + value, + startBit.toUByte(), + endBit.toUByte(), + ) + + public val index: Int + get() = _index.toInt() + public val startBit: Int + get() = _startBit.toInt() + public val endBit: Int + get() = _endBit.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetMemberExtraInfoUpdate + + if (_index != other._index) return false + if (value != other.value) return false + if (_startBit != other._startBit) return false + if (_endBit != other._endBit) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + value + result = 31 * result + _startBit.hashCode() + result = 31 * result + _endBit.hashCode() + return result + } + + override fun toString(): String = + "SetMemberExtraInfoUpdate(" + + "index=$index, " + + "value=$value, " + + "startBit=$startBit, " + + "endBit=$endBit" + + ")" + } + + /** + * Set member muted updates are used to mute or unmute members of this clan. + * @property index the index of this member within the clan's member list. + * @property muted whether to set the member muted or unmuted. + */ + public class SetMemberMutedUpdate private constructor( + private val _index: UShort, + public val muted: Boolean, + ) : Update { + public constructor( + index: Int, + muted: Boolean, + ) : this( + index.toUShort(), + muted, + ) + + public val index: Int + get() = _index.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetMemberMutedUpdate + + if (_index != other._index) return false + if (muted != other.muted) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + muted.hashCode() + return result + } + + override fun toString(): String = + "SetMemberMutedUpdate(" + + "index=$index, " + + "muted=$muted" + + ")" + } + + /** + * Int setting updates are used to modify the value of an integer-based + * setting of this clan. + * @property setting the id of the setting to modify, a 30-bit integer. + * @property value the 32-bit integer value to assign to that setting. + */ + public class SetIntSettingUpdate( + public val setting: Int, + public val value: Int, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetIntSettingUpdate + + if (setting != other.setting) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = setting + result = 31 * result + value + return result + } + + override fun toString(): String = + "SetIntSettingUpdate(" + + "setting=$setting, " + + "value=$value" + + ")" + } + + /** + * Long setting updates are used to modify the value of a long-based + * setting of this clan. + * @property setting the id of the setting to modify, a 30-bit integer. + * @property value the 64-bit long value to assign to that setting. + */ + public class SetLongSettingUpdate( + public val setting: Int, + public val value: Long, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetLongSettingUpdate + + if (setting != other.setting) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = setting + result = 31 * result + value.hashCode() + return result + } + + override fun toString(): String = + "SetLongSettingUpdate(" + + "setting=$setting, " + + "value=$value" + + ")" + } + + /** + * String setting updates are used to modify the values of string settings + * within the clan. + * @property setting the id of the setting to modify, a 30-bit integer. + * @property value the string value to assign to that setting. + */ + public class SetStringSettingUpdate( + public val setting: Int, + public val value: String, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetStringSettingUpdate + + if (setting != other.setting) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = setting + result = 31 * result + value.hashCode() + return result + } + + override fun toString(): String = + "SetStringSettingUpdate(" + + "setting=$setting, " + + "value='$value'" + + ")" + } + + /** + * Varbit setting updates are used to modify a bit-range of an integer-based + * setting of this clan. + * @property setting the id of the setting to modify, a 30-bit integer. + * @property value the new value to assign to the provided bit range. + * @property startBit the start bit of the bit range to modify + * @property endBit the end bit of the bit range ot modify + */ + public class SetVarbitSettingUpdate private constructor( + public val setting: Int, + public val value: Int, + private val _startBit: UByte, + private val _endBit: UByte, + ) : Update { + public constructor( + setting: Int, + value: Int, + startBit: Int, + endBit: Int, + ) : this( + setting, + value, + startBit.toUByte(), + endBit.toUByte(), + ) + + public val startBit: Int + get() = _startBit.toInt() + public val endBit: Int + get() = _endBit.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetVarbitSettingUpdate + + if (setting != other.setting) return false + if (value != other.value) return false + if (_startBit != other._startBit) return false + if (_endBit != other._endBit) return false + + return true + } + + override fun hashCode(): Int { + var result = setting + result = 31 * result + value + result = 31 * result + _startBit.hashCode() + result = 31 * result + _endBit.hashCode() + return result + } + + override fun toString(): String = + "SetVarbitSettingUpdate(" + + "setting=$setting, " + + "value=$value, " + + "startBit=$startBit, " + + "endBit=$endBit" + + ")" + } + + /** + * Clan name updates are used to modify the name of the clan. + * @property clanName the new clan name to assign to this clan. + */ + public class SetClanNameUpdate( + public val clanName: String, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetClanNameUpdate + + return clanName == other.clanName + } + + override fun hashCode(): Int = clanName.hashCode() + + override fun toString(): String = "SetClanNameUpdate(clanName='$clanName')" + } + + /** + * Clan owner updates are used to assign a new owner to this clan. + * @property index the index of the new owner in the clan's members list. + */ + public class SetClanOwnerUpdate( + public val index: Int, + ) : Update { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetClanOwnerUpdate + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "SetClanOwnerUpdate(index=$index)" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsFull.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsFull.kt new file mode 100644 index 000000000..864ae093e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/ClanSettingsFull.kt @@ -0,0 +1,512 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Clan settings full packet is used to update the clan's primary settings. + * @property clanType the clan being updated + * @property update the clan settings update to be performed + */ +public class ClanSettingsFull private constructor( + private val _clanType: Byte, + public val update: Update, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + update: Update, + ) : this( + clanType.toByte(), + update, + ) + + public val clanType: Int + get() = _clanType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return when (update) { + is JoinUpdate -> { + val sizePerAffinedMember = + 13 + + Byte.SIZE_BYTES + + Int.SIZE_BYTES + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + val settingsSize = + update + .settings + .sumOf { setting -> + when (setting) { + is IntClanSetting -> Int.SIZE_BYTES + Int.SIZE_BYTES + is LongClanSetting -> Int.SIZE_BYTES + Long.SIZE_BYTES + is StringClanSetting -> Int.SIZE_BYTES + estimateTextSize(setting.value) + } + } + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Int.SIZE_BYTES + + Int.SIZE_BYTES + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + + 13 + + Int.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + (update.affinedMembers.size * sizePerAffinedMember) + + (update.bannedMembers.size * 13) + + Short.SIZE_BYTES + + settingsSize + } + LeaveUpdate -> Byte.SIZE_BYTES + } + } + + override fun toString(): String = + "ClanSettingsFull(" + + "update=$update, " + + "clanType=$clanType" + + ")" + + public sealed interface Update + + /** + * Clan settings full join update is used to make the player join a clan. + * @property updateNum the number of changes done to this clan's settings + * @property creationTime the epoch time minute when the clan was created + * @property clanName the name of the clan + * @property allowUnaffined whether to allow guests to join the clan + * @property talkRank the minimum rank needed to talk in this clan chat + * @property kickRank the minimum rank needed to kick members from this clan + * @property lootshareRank the minimum rank needed to toggle lootshare, unused in OldSchool. + * @property coinshareRank the minimum rank needed to toggle coinshare, unused in OldSchool. + * @property affinedMembers the list of affined members in this clan + * @property bannedMembers the list of banned members in this clan + * @property settings the list of settings to apply to this clan + */ + public class JoinUpdate private constructor( + private val _flags: UByte, + public val updateNum: Int, + public val creationTime: Int, + public val clanName: String, + public val allowUnaffined: Boolean, + private val _talkRank: Byte, + private val _kickRank: Byte, + private val _lootshareRank: Byte, + private val _coinshareRank: Byte, + public val affinedMembers: List, + public val bannedMembers: List, + public val settings: List, + ) : Update { + public constructor( + updateNum: Int, + creationTime: Int, + clanName: String, + allowUnaffined: Boolean, + talkRank: Int, + kickRank: Int, + lootshareRank: Int, + coinshareRank: Int, + affinedMembers: List, + bannedMembers: List, + settings: List, + hasAffinedHashes: Boolean = false, + hasAffinedDisplayNames: Boolean = true, + ) : this( + (if (hasAffinedHashes) FLAG_HAS_AFFINED_HASHES else 0) + .or(if (hasAffinedDisplayNames) FLAG_HAS_AFFINED_DISPLAY_NAMES else 0) + .toUByte(), + updateNum, + creationTime, + clanName, + allowUnaffined, + talkRank.toByte(), + kickRank.toByte(), + lootshareRank.toByte(), + coinshareRank.toByte(), + affinedMembers, + bannedMembers, + settings, + ) + + init { + require(affinedMembers.size <= 0xFFFF) { + "Affined member count cannot exceed 65535" + } + require(bannedMembers.size <= 0xFF) { + "Banned member count cannot exceed 255" + } + } + + public val flags: Int + get() = _flags.toInt() + public val talkRank: Int + get() = _talkRank.toInt() + public val kickRank: Int + get() = _kickRank.toInt() + public val lootshareRank: Int + get() = _lootshareRank.toInt() + public val coinshareRank: Int + get() = _coinshareRank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as JoinUpdate + + if (_flags != other._flags) return false + if (updateNum != other.updateNum) return false + if (creationTime != other.creationTime) return false + if (clanName != other.clanName) return false + if (allowUnaffined != other.allowUnaffined) return false + if (_talkRank != other._talkRank) return false + if (_kickRank != other._kickRank) return false + if (_lootshareRank != other._lootshareRank) return false + if (_coinshareRank != other._coinshareRank) return false + if (affinedMembers != other.affinedMembers) return false + if (bannedMembers != other.bannedMembers) return false + if (settings != other.settings) return false + + return true + } + + override fun hashCode(): Int { + var result = _flags.hashCode() + result = 31 * result + updateNum + result = 31 * result + creationTime + result = 31 * result + clanName.hashCode() + result = 31 * result + allowUnaffined.hashCode() + result = 31 * result + _talkRank + result = 31 * result + _kickRank + result = 31 * result + _lootshareRank + result = 31 * result + _coinshareRank + result = 31 * result + affinedMembers.hashCode() + result = 31 * result + bannedMembers.hashCode() + result = 31 * result + settings.hashCode() + return result + } + + override fun toString(): String = + "JoinUpdate(" + + "flags=$flags, " + + "updateNum=$updateNum, " + + "creationTime=$creationTime, " + + "clanName='$clanName', " + + "allowUnaffined=$allowUnaffined, " + + "talkRank=$talkRank, " + + "kickRank=$kickRank, " + + "lootshareRank=$lootshareRank, " + + "coinshareRank=$coinshareRank, " + + "affinedMembers=$affinedMembers, " + + "bannedMembers=$bannedMembers, " + + "settings=$settings" + + ")" + } + + public data object LeaveUpdate : Update + + public sealed interface ClanMember + + /** + * An affined clan member is someone who has joined the clan permanently, + * e.g. not as a guest. + * @property hash the 64-bit hash of this member. + * @property name the name of this member. + * @property rank this member's rank in this clan + * @property extraInfo extra information bitpacked into an integer, to be read and used + * within clientscripts. + * @property joinRuneDay the rune day when the member joined this clan + * @property muted whether this member is muted in this clan. + */ + public class AffinedClanMember private constructor( + public val hash: Long, + public val name: String?, + private val _rank: Byte, + public val extraInfo: Int, + private val _joinRuneDay: UShort, + public val muted: Boolean, + ) : ClanMember { + /** + * Constructor for when the hash and name are both being transmitted. + */ + public constructor( + hash: Long, + name: String, + rank: Int, + extraInfo: Int, + joinRuneDay: Int, + muted: Boolean, + ) : this( + hash, + name, + rank.toByte(), + extraInfo, + joinRuneDay.toUShort(), + muted, + ) + + /** + * Constructor for when only the name, and no hashes are being transmitted. + */ + public constructor( + name: String, + rank: Int, + extraInfo: Int, + joinRuneDay: Int, + muted: Boolean, + ) : this( + 0, + name, + rank.toByte(), + extraInfo, + joinRuneDay.toUShort(), + muted, + ) + + /** + * Constructor for when only the hash and no name is being transmitted. + */ + public constructor( + hash: Long, + rank: Int, + extraInfo: Int, + joinRuneDay: Int, + muted: Boolean, + ) : this( + hash, + null, + rank.toByte(), + extraInfo, + joinRuneDay.toUShort(), + muted, + ) + + public val rank: Int + get() = _rank.toInt() + public val joinRuneDay: Int + get() = _joinRuneDay.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AffinedClanMember + + if (hash != other.hash) return false + if (name != other.name) return false + if (_rank != other._rank) return false + if (extraInfo != other.extraInfo) return false + if (_joinRuneDay != other._joinRuneDay) return false + if (muted != other.muted) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + _rank + result = 31 * result + extraInfo + result = 31 * result + _joinRuneDay.hashCode() + result = 31 * result + muted.hashCode() + return result + } + + override fun toString(): String = + "AffinedClanMember(" + + "hash=$hash, " + + "name=$name, " + + "rank=$rank, " + + "extraInfo=$extraInfo, " + + "joinRuneDay=$joinRuneDay, " + + "muted=$muted" + + ")" + } + + /** + * A banned clan member is someone who has joined the clan permanently, + * but has been banned from it. + * @property hash the 64-bit hash of this member. + * @property name the name of this member. + */ + public class BannedClanMember( + public val hash: Long, + public val name: String?, + ) : ClanMember { + public constructor( + hash: Long, + ) : this( + hash, + null, + ) + + public constructor( + name: String, + ) : this( + 0, + name, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BannedClanMember + + if (hash != other.hash) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "BannedClanMember(" + + "hash=$hash, " + + "name=$name" + + ")" + } + + public sealed interface ClanSetting + + /** + * Integer-value based clan setting + * @property id the id the of clan setting. + * Note that the last two bits(including the sign bit) may not be used. + * @property value the value of this setting, a 32-bit integer. + */ + public class IntClanSetting( + public val id: Int, + public val value: Int, + ) : ClanSetting { + init { + require(id and 0x3FFFFFFF.inv() == 0) { + "Id cannot be larger than 30 bits" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IntClanSetting + + if (id != other.id) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + value + return result + } + + override fun toString(): String = + "IntClanSetting(" + + "id=$id, " + + "value=$value" + + ")" + } + + /** + * Long-value based clan setting + * @property id the id the of clan setting. + * Note that the last two bits(including the sign bit) may not be used. + * @property value the value of this setting, a 64-bit long. + */ + public class LongClanSetting( + public val id: Int, + public val value: Long, + ) : ClanSetting { + init { + require(id and 0x3FFFFFFF.inv() == 0) { + "Id cannot be larger than 30 bits" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LongClanSetting + + if (id != other.id) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + value.hashCode() + return result + } + + override fun toString(): String = + "LongClanSetting(" + + "id=$id, " + + "value=$value" + + ")" + } + + /** + * String-value based clan setting + * @property id the id the of clan setting. + * Note that the last two bits(including the sign bit) may not be used. + * @property value the value of this setting, a string. + */ + public class StringClanSetting( + public val id: Int, + public val value: String, + ) : ClanSetting { + init { + require(id and 0x3FFFFFFF.inv() == 0) { + "Id cannot be larger than 30 bits" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringClanSetting + + if (id != other.id) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + value.hashCode() + return result + } + + override fun toString(): String = + "StringClanSetting(" + + "id=$id, " + + "value='$value'" + + ")" + } + + public companion object { + public const val FLAG_HAS_AFFINED_HASHES: Int = 0x1 + public const val FLAG_HAS_AFFINED_DISPLAY_NAMES: Int = 0x2 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannel.kt new file mode 100644 index 000000000..b76b635a7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannel.kt @@ -0,0 +1,115 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateHuffmanEncodedTextSize +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Message clan channel is used to send messages within a clan channel + * that the player is in. + * @property clanType the type of the clan the player is in + * @property name the name of the player sending the message + * @property worldId the id of the world from which the message is sent + * @property worldMessageCounter the world-local message counter. + * Each world must have its own message counter which is used to create + * a unique id for each message. This message counter must be + * incrementing with each message that is sent out. + * If two messages share the same unique id (which is a combination of + * the [worldId] and the [worldMessageCounter] properties), + * the client will not render the second message if it already has one + * received in the last 100 messages. + * It is additionally worth noting that servers with low population + * should probably not start the counter at the same value with each + * game boot, as the probability of multiple messages coinciding + * is relatively high in that scenario, given the low quantity of + * messages sent out to begin with. + * Additionally, only the first 24 bits of the counter are utilized, + * meaning a value from 0 to 16,777,215 (inclusive). + * A good starting point for message counting would be to take the + * hour of the year and multiply it by 50,000 when the server boots + * up. This means the roll-over happens roughly after every two weeks. + * Fine-tuning may be used to make it more granular, but the overall + * idea remains the same. + * @property chatCrownType the chat crown type to be rendered next to the name + * @property message the message to send + */ +public class MessageClanChannel private constructor( + private val _clanType: Byte, + public val name: String, + private val _worldId: UShort, + public val worldMessageCounter: Int, + private val _chatCrownType: UByte, + public val message: String, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + name: String, + worldId: Int, + worldMessageCounter: Int, + chatCrownType: Int, + message: String, + ) : this( + clanType.toByte(), + name, + worldId.toUShort(), + worldMessageCounter, + chatCrownType.toUByte(), + message, + ) + + public val clanType: Int + get() = _clanType.toInt() + public val worldId: Int + get() = _worldId.toInt() + public val chatCrownType: Int + get() = _chatCrownType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return Byte.SIZE_BYTES + + estimateTextSize(name) + + Short.SIZE_BYTES + + 3 + + Byte.SIZE_BYTES + + estimateHuffmanEncodedTextSize(message) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageClanChannel + + if (_clanType != other._clanType) return false + if (name != other.name) return false + if (_worldId != other._worldId) return false + if (worldMessageCounter != other.worldMessageCounter) return false + if (_chatCrownType != other._chatCrownType) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = _clanType.toInt() + result = 31 * result + name.hashCode() + result = 31 * result + _worldId.hashCode() + result = 31 * result + worldMessageCounter + result = 31 * result + _chatCrownType.hashCode() + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessageClanChannel(" + + "clanType=$clanType, " + + "name='$name', " + + "worldId=$worldId, " + + "worldLocalCounter=$worldMessageCounter, " + + "chatCrownType=$chatCrownType, " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannelSystem.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannelSystem.kt new file mode 100644 index 000000000..6b7cecf63 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/MessageClanChannelSystem.kt @@ -0,0 +1,96 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateHuffmanEncodedTextSize + +/** + * Message clan channel system is used to send system messages + * within a clan channel that the player is in + * @property clanType the type of the clan the player is in + * @property worldId the id of the world from which the message is sent + * @property worldMessageCounter the world-local message counter. + * Each world must have its own message counter which is used to create + * a unique id for each message. This message counter must be + * incrementing with each message that is sent out. + * If two messages share the same unique id (which is a combination of + * the [worldId] and the [worldMessageCounter] properties), + * the client will not render the second message if it already has one + * received in the last 100 messages. + * It is additionally worth noting that servers with low population + * should probably not start the counter at the same value with each + * game boot, as the probability of multiple messages coinciding + * is relatively high in that scenario, given the low quantity of + * messages sent out to begin with. + * Additionally, only the first 24 bits of the counter are utilized, + * meaning a value from 0 to 16,777,215 (inclusive). + * A good starting point for message counting would be to take the + * hour of the year and multiply it by 50,000 when the server boots + * up. This means the roll-over happens roughly after every two weeks. + * Fine-tuning may be used to make it more granular, but the overall + * idea remains the same. + * @property message the message to send + */ +public class MessageClanChannelSystem private constructor( + private val _clanType: Byte, + private val _worldId: UShort, + public val worldMessageCounter: Int, + public val message: String, +) : OutgoingGameMessage { + public constructor( + clanType: Int, + worldId: Int, + worldMessageCounter: Int, + message: String, + ) : this( + clanType.toByte(), + worldId.toUShort(), + worldMessageCounter, + message, + ) + + public val clanType: Int + get() = _clanType.toInt() + public val worldId: Int + get() = _worldId.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return Byte.SIZE_BYTES + + Short.SIZE_BYTES + + 3 + + estimateHuffmanEncodedTextSize(message) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageClanChannelSystem + + if (_clanType != other._clanType) return false + if (_worldId != other._worldId) return false + if (worldMessageCounter != other.worldMessageCounter) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = _clanType.toInt() + result = 31 * result + _worldId.hashCode() + result = 31 * result + worldMessageCounter + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessageClanChannelSystem(" + + "clanType=$clanType, " + + "worldId=$worldId, " + + "worldLocalCounter=$worldMessageCounter, " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClan.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClan.kt new file mode 100644 index 000000000..40a60b6c3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClan.kt @@ -0,0 +1,136 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Var clans are used to transmit a variable of a clan to the user. + * It is important to note that the data type must align with what + * is defined in the cache, or the client will not be decoding it + * correctly, which will most likely lead to a disconnection. + * @property id the id of the varclan + * @property value the varclan data value. + * Use [VarClanIntData], [VarClanLongData] or [VarClanStringData] to + * transmit the payload, depending on the defined type in the cache. + */ +public class VarClan private constructor( + private val _id: UShort, + public val value: VarClanData, +) : OutgoingGameMessage { + public constructor( + id: Int, + value: VarClanData, + ) : this( + id.toUShort(), + value, + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + val payloadSize = + when (value) { + is VarClanIntData -> Int.SIZE_BYTES + is VarClanLongData -> Long.SIZE_BYTES + is VarClanStringData -> Byte.SIZE_BYTES + estimateTextSize(value.value) + } + return Short.SIZE_BYTES + payloadSize + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarClan + + if (_id != other._id) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + value.hashCode() + return result + } + + override fun toString(): String = + "VarClan(" + + "id=$id, " + + "value=$value" + + ")" + + public sealed interface VarClanData + + /** + * Var clan int data is used to transmit a 32-bit integer as a varclan + * value. + * @property value the 32-bit integer value for this varclan. + */ + public class VarClanIntData( + public val value: Int, + ) : VarClanData { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarClanIntData + + return value == other.value + } + + override fun hashCode(): Int = value + + override fun toString(): String = "VarClanIntData(value=$value)" + } + + /** + * Var clan int data is used to transmit a 64-bit long as a varclan + * value. + * @property value the 64-bit long value for this varclan. + */ + public class VarClanLongData( + public val value: Long, + ) : VarClanData { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarClanLongData + + return value == other.value + } + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "VarClanLongData(value=$value)" + } + + /** + * Var clan int data is used to transmit a string as a varclan + * value. + * @property value the string for this varclan. + */ + public class VarClanStringData( + public val value: String, + ) : VarClanData { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarClanStringData + + return value == other.value + } + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "VarClanStringData(value='$value')" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanDisable.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanDisable.kt new file mode 100644 index 000000000..2a3eee6da --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanDisable.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Var clan disable packet is used to clear out a var domain + * in the client, intended to be sent as the player leaves a clan. + */ +public data object VarClanDisable : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanEnable.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanEnable.kt new file mode 100644 index 000000000..ed10bd113 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/clan/VarClanEnable.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.outgoing.clan + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Var clan enable packet is used to initialize a new var domain + * in the client, intended to be sent as the player joins a clan. + */ +public data object VarClanEnable : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/MessageFriendChannel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/MessageFriendChannel.kt new file mode 100644 index 000000000..4e21a4723 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/MessageFriendChannel.kt @@ -0,0 +1,133 @@ +package net.rsprot.protocol.game.outgoing.friendchat + +import net.rsprot.compression.Base37 +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateHuffmanEncodedTextSize +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Message friendchannel is used to transmit messages within a friend + * chat channel. + * @property sender the name of the player who is sending the message + * @property channelName the name of the friend chat channel + * @property worldMessageCounter the world-local message counter. + * Each world must have its own message counter which is used to create + * a unique id for each message. This message counter must be + * incrementing with each message that is sent out. + * If two messages share the same unique id (which is a combination of + * the [worldId] and the [worldMessageCounter] properties), + * the client will not render the second message if it already has one + * received in the last 100 messages. + * It is additionally worth noting that servers with low population + * should probably not start the counter at the same value with each + * game boot, as the probability of multiple messages coinciding + * is relatively high in that scenario, given the low quantity of + * messages sent out to begin with. + * Additionally, only the first 24 bits of the counter are utilized, + * meaning a value from 0 to 16,777,215 (inclusive). + * A good starting point for message counting would be to take the + * hour of the year and multiply it by 50,000 when the server boots + * up. This means the roll-over happens roughly after every two weeks. + * Fine-tuning may be used to make it more granular, but the overall + * idea remains the same. + * @property chatCrownType the id of the crown to render next to the + * name of the sender. + * @property message the message to be sent in the friend chat + * channel. + */ +public class MessageFriendChannel private constructor( + public val sender: String, + public val channelNameBase37: Long, + private val _worldId: UShort, + public val worldMessageCounter: Int, + private val _chatCrownType: UByte, + public val message: String, +) : OutgoingGameMessage { + public constructor( + sender: String, + channelName: String, + worldId: Int, + worldMessageCounter: Int, + chatCrownType: Int, + message: String, + ) : this( + sender, + Base37.encode(channelName), + worldId.toUShort(), + worldMessageCounter, + chatCrownType.toUByte(), + message, + ) + + public constructor( + sender: String, + channelNameBase37: Long, + worldId: Int, + worldMessageCounter: Int, + chatCrownType: Int, + message: String, + ) : this( + sender, + channelNameBase37, + worldId.toUShort(), + worldMessageCounter, + chatCrownType.toUByte(), + message, + ) + + public val channelName: String + get() = Base37.decodeWithCase(channelNameBase37) + public val worldId: Int + get() = _worldId.toInt() + public val chatCrownType: Int + get() = _chatCrownType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return estimateTextSize(sender) + + Long.SIZE_BYTES + + Short.SIZE_BYTES + + 3 + + Byte.SIZE_BYTES + + estimateHuffmanEncodedTextSize(message) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageFriendChannel + + if (sender != other.sender) return false + if (channelNameBase37 != other.channelNameBase37) return false + if (_worldId != other._worldId) return false + if (worldMessageCounter != other.worldMessageCounter) return false + if (_chatCrownType != other._chatCrownType) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = sender.hashCode() + result = 31 * result + channelNameBase37.hashCode() + result = 31 * result + _worldId.hashCode() + result = 31 * result + worldMessageCounter + result = 31 * result + _chatCrownType.hashCode() + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessageFriendChannel(" + + "sender='$sender', " + + "channelName='$channelName', " + + "worldId=$worldId, " + + "worldMessageCounter=$worldMessageCounter, " + + "chatCrownType=$chatCrownType, " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFull.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFull.kt new file mode 100644 index 000000000..e2ad83af0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFull.kt @@ -0,0 +1,69 @@ +package net.rsprot.protocol.game.outgoing.friendchat + +public sealed class UpdateFriendChatChannelFull { + public abstract val channelOwner: String + public abstract val channelName: String + public abstract val kickRank: Int + public abstract val entries: List + + /** + * A class to contain all the properties of a player in a friend chat. + * @property name the name of the player that is in the friend chat + * @property worldId the id of the world in which the given user is + * @property rank the rank of the given used in this friend chat + * @property worldName world name, unused in OldSchool RuneScape. + */ + public class FriendChatEntry private constructor( + public val name: String, + private val _worldId: UShort, + private val _rank: Byte, + public val worldName: String, + ) { + public constructor( + name: String, + worldId: Int, + rank: Int, + worldName: String, + ) : this( + name, + worldId.toUShort(), + rank.toByte(), + worldName, + ) + + public val worldId: Int + get() = _worldId.toInt() + public val rank: Int + get() = _rank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FriendChatEntry + + if (name != other.name) return false + if (_worldId != other._worldId) return false + if (_rank != other._rank) return false + if (worldName != other.worldName) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _worldId.hashCode() + result = 31 * result + _rank.hashCode() + result = 31 * result + worldName.hashCode() + return result + } + + override fun toString(): String = + "FriendChatEntry(" + + "name='$name', " + + "worldId=$worldId, " + + "rank=$rank, " + + "worldName='$worldName'" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFullV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFullV2.kt new file mode 100644 index 000000000..fab0c3bce --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelFullV2.kt @@ -0,0 +1,127 @@ +package net.rsprot.protocol.game.outgoing.friendchat + +import net.rsprot.compression.Base37 +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update friendchat channel full V2 is used to send full channel updates + * where the list of entries has a size of more than 255. + * It can also support sizes below that, but for sizes in range of 128..255, + * it is more efficient by 1 byte to use V1 of this packet. + */ +public class UpdateFriendChatChannelFullV2( + public val updateType: UpdateType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return when (val update = updateType) { + is JoinUpdate -> { + val sizePerEntry = + 13 + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + 13 + + Long.SIZE_BYTES + + Byte.SIZE_BYTES + + (if (update.entries.size >= 0x80) Short.SIZE_BYTES else Byte.SIZE_BYTES) + + (update.entries.size * sizePerEntry) + } + LeaveUpdate -> 0 + } + } + + override fun toString(): String = "UpdateFriendChatChannelFullV2(updateType=$updateType)" + + public sealed interface UpdateType + + /** + * Join updates are used to enter a friendchat channel. + * @property channelOwner the name of the player who owns this channel + * @property channelName the name of the friend chat channel. + * This name must be compatible with base-37 encoding, meaning + * it cannot have special symbols, and it must be 12 characters of less. + * @property kickRank the minimum rank id to kick another player from + * the friend chat. + * @property entries the list of friend chat entries to be added. + * If the list is empty, the client will only refresh the details and leave + * the members of the chat untouched. + */ + public class JoinUpdate private constructor( + override val channelOwner: String, + public val channelNameBase37: Long, + private val _kickRank: Byte, + override val entries: List, + ) : UpdateFriendChatChannelFull(), + UpdateType { + public constructor( + channelOwner: String, + channelName: String, + kickRank: Int, + entries: List, + ) : this( + channelOwner, + Base37.encode(channelName), + kickRank.toByte(), + entries, + ) + + public constructor( + channelOwner: String, + channelNameBase37: Long, + kickRank: Int, + entries: List, + ) : this( + channelOwner, + channelNameBase37, + kickRank.toByte(), + entries, + ) + + override val channelName: String + get() = Base37.decode(channelNameBase37) + override val kickRank: Int + get() = _kickRank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as JoinUpdate + + if (channelOwner != other.channelOwner) return false + if (channelNameBase37 != other.channelNameBase37) return false + if (_kickRank != other._kickRank) return false + if (entries != other.entries) return false + + return true + } + + override fun hashCode(): Int { + var result = channelOwner.hashCode() + result = 31 * result + channelNameBase37.hashCode() + result = 31 * result + _kickRank.hashCode() + result = 31 * result + entries.hashCode() + return result + } + + override fun toString(): String = + "UpdateFriendChatChannelFullV2.JoinUpdate(" + + "channelOwner='$channelOwner', " + + "channelName='$channelName', " + + "kickRank=$kickRank, " + + "entries=$entries" + + ")" + } + + /** + * Leave updates are used to leave a friendchat channel. + */ + public data object LeaveUpdate : UpdateType { + override fun toString(): String = "UpdateFriendChatChannelFullV2.LeaveUpdate()" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelSingleUser.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelSingleUser.kt new file mode 100644 index 000000000..e285b6d3e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/friendchat/UpdateFriendChatChannelSingleUser.kt @@ -0,0 +1,162 @@ +package net.rsprot.protocol.game.outgoing.friendchat + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Update friendchat singleuser is used to perform a change + * to a friend chat for a single user, whether that be + * adding the user to the friend chat, or removing them. + * @property user the user entry being removed or added. + * Use [AddedFriendChatUser] and [RemovedFriendChatUser] + * respectively to perform different updates. + */ +public class UpdateFriendChatChannelSingleUser( + public val user: FriendChatUser, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return estimateTextSize(user.name) + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + + (if (user is AddedFriendChatUser) estimateTextSize(user.worldName) else 0) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateFriendChatChannelSingleUser + + return user == other.user + } + + override fun hashCode(): Int = user.hashCode() + + override fun toString(): String = "UpdateFriendChatChannelSingleUser(user=$user)" + + public sealed interface FriendChatUser { + public val name: String + public val worldId: Int + public val rank: Int + } + + /** + * Added friendchat user indicates a single player + * that is being added to the given friend chat channel. + * @property name the name of the player being added to the friend chat + * @property worldId the id of the world in which that player resides + * @property rank the rank of that player in the friend chat + * @property worldName world name, unused in OldSchool RuneScape. + */ + public class AddedFriendChatUser private constructor( + override val name: String, + private val _worldId: UShort, + private val _rank: Byte, + public val worldName: String, + ) : FriendChatUser { + public constructor( + name: String, + worldId: Int, + rank: Int, + string: String, + ) : this( + name, + worldId.toUShort(), + rank.toByte(), + string, + ) { + require(rank != -128) { + "Rank cannot be -128 as that is used to indicate a removed entry." + } + } + + override val worldId: Int + get() = _worldId.toInt() + override val rank: Int + get() = _rank.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddedFriendChatUser + + if (name != other.name) return false + if (_worldId != other._worldId) return false + if (_rank != other._rank) return false + if (worldName != other.worldName) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _worldId.hashCode() + result = 31 * result + _rank + result = 31 * result + worldName.hashCode() + return result + } + + override fun toString(): String = + "AddedFriendChatUser(" + + "name='$name', " + + "worldId=$worldId, " + + "rank=$rank, " + + "worldName='$worldName'" + + ")" + } + + /** + * Removed friendchat user indicates that a player + * is leaving a friend chat channel. + * @property name the name of the player leaving this friend chat channel + * @property worldId the id of the world in which the player resided. + * Note that the world id must match up or the user will not be removed. + */ + public class RemovedFriendChatUser private constructor( + override val name: String, + private val _worldId: UShort, + ) : FriendChatUser { + public constructor( + name: String, + worldId: Int, + ) : this( + name, + worldId.toUShort(), + ) + + override val worldId: Int + get() = _worldId.toInt() + override val rank: Int + get() = -128 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RemovedFriendChatUser + + if (name != other.name) return false + if (_worldId != other._worldId) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + _worldId.hashCode() + return result + } + + override fun toString(): String = + "RemovedFriendChatUser(" + + "name='$name', " + + "worldId=$worldId" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarExtendedInfoWriter.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarExtendedInfoWriter.kt new file mode 100644 index 000000000..64429dec7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarExtendedInfoWriter.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.OnDemandExtendedInfoEncoder + +/** + * A base class for client-specific extended info writers. + * @param oldSchoolClientType the client for which the encoders are created. + * @param encoders the set of extended info encoders for the given [oldSchoolClientType]. + */ +public abstract class AvatarExtendedInfoWriter( + public val oldSchoolClientType: OldSchoolClientType, + public val encoders: E, +) { + /** + * Main function to write all the extended info blocks over. + * The extended info blocks must be in the exact order as they are + * read within the client, and this function is responsible + * for converting library-specific-constants to client-specific-flags. + * + * @param buffer the buffer into which to write the extended info block. + * @param localIndex the index of the avatar that owns these extended info blocks. + * @param observerIndex the index of the player observing this avatar. + * @param flag the constant-flag of all the extended info blocks which must be + * translated and written to the buffer. + * @param blocks the wrapper class around all the extended info blocks. + * The blocks which are flagged will be written over. + */ + public abstract fun pExtendedInfo( + buffer: JagByteBuf, + localIndex: Int, + observerIndex: Int, + flag: Int, + blocks: B, + flagWriteIndex: Int, + ) + + /** + * Natively copies cached data from the pre-computed extended info buffer over + * into the primary player info buffer. + * @param buffer the primary player info buffer. + * @param block the extended info block which to copy over. + * @throws IllegalStateException if the given buffer has not been precomputed + * for the given client type. + */ + protected fun pCachedData( + buffer: JagByteBuf, + block: ExtendedInfo<*, *>, + ) { + val precomputed = + checkNotNull(block.getBuffer(oldSchoolClientType)) { + "Buffer has not been computed on client $oldSchoolClientType, ${block.javaClass.name}" + } + buffer.buffer.writeBytes(precomputed, precomputed.readerIndex(), precomputed.readableBytes()) + } + + /** + * Writes on-demand extended info block. This is for extended info blocks which + * cannot be pre-computed as they depend on the observer for information, + * such as tinted hitmarks. + * @param buffer the primary player info buffer. + * @param localIndex the index of the avatar that owns this extended info block. + * @param block the extended info block to compute and write into the primary buffer. + * @param observerIndex the index of the avatar observing the avatar who owns this + * extended info block. + */ + protected fun , E : OnDemandExtendedInfoEncoder> pOnDemandData( + buffer: JagByteBuf, + localIndex: Int, + block: T, + observerIndex: Int, + ) { + val encoder = + checkNotNull(block.getEncoder(oldSchoolClientType)) { + "Encoder has not been set for client $oldSchoolClientType" + } + encoder.encode( + buffer, + observerIndex, + localIndex, + block, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarPriority.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarPriority.kt new file mode 100644 index 000000000..6ab0be9d3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/AvatarPriority.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.game.outgoing.info + +/** + * An enum defining the possible avatar priority values. + * For players, the [LOW] priority is the default. + * For NPCs, the [NORMAL] priority is the default. + */ +public enum class AvatarPriority( + public val bitcode: Int, +) { + LOW(0x0), + NORMAL(0x1), +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ByteBufRecycler.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ByteBufRecycler.kt new file mode 100644 index 000000000..072ccf102 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ByteBufRecycler.kt @@ -0,0 +1,87 @@ +package net.rsprot.protocol.game.outgoing.info + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.util.ReferenceCountUtil +import io.netty.util.ReferenceCounted +import net.rsprot.protocol.internal.RSProtFlags +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * The byte buf recycler is responsible for releasing any pooled byte buffers which were + * not safely released by the server. This is due to the library relying on the server to + * write the pre-calculated buffers to Netty, which would then do the releasing. If the buffers + * are calculated, but never written to Netty, we would otherwise run into issues with byte buffers + * leaking. + * @property forceReleaseThreshold the number of cycles a buffer can be alive for before forcibly + * cleaning by the recycler. The default is 50 cycles, meaning 30 seconds. + * @property cycleCount the current number of cycles that the recycler has gone through. + * @property buffers a concurrent queue of tracked buffers. + */ +internal class ByteBufRecycler( + private val forceReleaseThreshold: Int = RSProtFlags.byteBufRecyclerCycleThreshold, +) { + private var cycleCount: Int = 0 + private val buffers: Queue = ConcurrentLinkedQueue() + + /** + * Adds a buffer to the recycler, ensuring it will eventually get safely released back + * into the pool. Buffers which have no references will not be added. + * @param buffer the byte buffer to be tracked and safely released in due time. + */ + fun add(buffer: ByteBuf) { + // If the buffer is already released, or it is not something that gets pooled anyway, + // avoid storing it in our tracking mechanism. + // Non-pooled buffers will always report a non-positive ref count. + if (buffer.refCnt() <= 0) { + return + } + buffers.add(RecycledByteBuf(cycleCount, buffer.retain())) + } + + operator fun plusAssign(buffer: ByteBuf) { + add(buffer) + } + + /** + * Increments the cycle count and iterates through all tracked buffers, clearing out any + * which were previously already released, and forcibly releasing any which have expired. + */ + fun cycle() { + val cycle = cycleCount++ + val iterator = buffers.iterator() + while (iterator.hasNext()) { + val next = iterator.next() + try { + val elapsed = cycle - next.cycle + val refCount = next.refCnt() + if (refCount <= 1 || elapsed >= forceReleaseThreshold) { + if (refCount > 0) { + ReferenceCountUtil.safeRelease(next, refCount) + } + iterator.remove() + } + } catch (e: Exception) { + logger.error(e) { + "Error recycling buffer $next" + } + } + } + } + + /** + * An object that wraps pooled byte buffers which need to be released in the future. + * @property cycle the [net.rsprot.protocol.game.outgoing.info.ByteBufRecycler.cycle] on which + * this byte buffer was added to tracking. + * @property buffer the pooled byte buffer which needs to be released in the future. + */ + private data class RecycledByteBuf( + val cycle: Int, + val buffer: ByteBuf, + ) : ReferenceCounted by buffer + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoPackets.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoPackets.kt new file mode 100644 index 000000000..6219312c2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoPackets.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.outgoing.info + +/** + * A class that holds all the info packets for a given player in a single game cycle. + * @property rootWorldInfoPackets all the packets that are part of the root world. + * This includes world entity info and player info, as the two are calculated globally once. + * @property removedWorldIndices the ids of the worlds that were removed from high resolution in this cycle, + * allowing the server to stop tracking the worlds for zone updates. + * @property activeWorlds a list of world info packets per active world. + */ +public class InfoPackets( + public val rootWorldInfoPackets: RootWorldInfoPackets, + public val removedWorldIndices: List, + public val activeWorlds: List, +) diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoProtocols.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoProtocols.kt new file mode 100644 index 000000000..a7216825f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoProtocols.kt @@ -0,0 +1,56 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol +import net.rsprot.protocol.internal.checkCommunicationThread + +/** + * A combination class for the three info protocols, making it easier for servers + * to consume and keep everything up to date. + */ +public class InfoProtocols( + public val playerInfoProtocol: PlayerInfoProtocol, + public val npcInfoProtocol: NpcInfoProtocol, + public val worldEntityInfoProtocol: WorldEntityProtocol, +) { + /** + * Allocates a combination info object allowing the server to communicate through a single + * means, rather than each one individually. + * @param idx the index of the player that is allocating the infos. + * @param oldSchoolClientType the client type used by the player. + */ + public fun alloc( + idx: Int, + oldSchoolClientType: OldSchoolClientType, + ): Infos { + checkCommunicationThread() + val worldEntityInfo = worldEntityInfoProtocol.alloc(idx, oldSchoolClientType) + return Infos( + playerInfoProtocol.alloc(idx, oldSchoolClientType, worldEntityInfo), + npcInfoProtocol.alloc(idx, oldSchoolClientType, worldEntityInfo), + worldEntityInfo, + ) + } + + /** + * Deallocates the provided info object. + * @param infos the infos previously allocated. + */ + public fun dealloc(infos: Infos) { + playerInfoProtocol.dealloc(infos.playerInfo) + npcInfoProtocol.dealloc(infos.npcInfo) + worldEntityInfoProtocol.dealloc(infos.worldEntityInfo) + } + + /** + * Performs a full update across world entity info, player info and npc info, + * in the provided order. + */ + public fun update() { + worldEntityInfoProtocol.update() + playerInfoProtocol.update() + npcInfoProtocol.update() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoRepository.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoRepository.kt new file mode 100644 index 000000000..65fdc4f44 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/InfoRepository.kt @@ -0,0 +1,187 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.protocol.common.client.OldSchoolClientType +import java.lang.ref.ReferenceQueue +import java.lang.ref.SoftReference + +/** + * The info repository class is responsible for allocating and re-using various info implementations. + */ +@Suppress("DuplicatedCode") +internal abstract class InfoRepository( + private val allocator: ( + index: Int, + oldSchoolClientType: OldSchoolClientType, + info: I, + ) -> T, +) { + /** + * The backing elements array used to store currently-in-use objects. + */ + protected abstract val elements: Array + + /** + * The reference queue used to store soft references of the objects after they have been + * returned to this structure. The references may release their object if the JVM + * requires that memory, but only as a last resort, before having to throw an + * out of memory exception. + */ + private val queue: ReferenceQueue = ReferenceQueue() + + /** + * Gets the current element at index [idx], or null if it doesn't exist. + * @param idx the index of the player info object to obtain + * @throws ArrayIndexOutOfBoundsException if the index is below zero, or above [capacity]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + fun getOrNull(idx: Int): T? = elements[idx] + + /** + * Gets the current element at index [idx]. + * @param idx the index of the player info object to obtain + * @throws ArrayIndexOutOfBoundsException if the index is below zero, or above [capacity]. + * @throws IllegalStateException if the element at index [idx] is null. + */ + @Throws( + ArrayIndexOutOfBoundsException::class, + IllegalStateException::class, + ) + operator fun get(idx: Int): T = checkNotNull(elements[idx]) + + /** + * Gets the maximum capacity of this object array. + */ + fun capacity(): Int = elements.size + + /** + * Allocates a new element at the specified [idx]. + * This function will first check if there are any unused objects + * left in the [queue]. If there are, obtains the reference and executes + * [net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject.onAlloc] in it, + * which is responsible for cleaning the object so that it can be re-used again. + * This is preferably done on allocations, rather than de-allocations, + * as there's a chance the JVM will just garbage collect + * the object without it ever being re-allocated. + * + * @param idx the index of the element to obtain. + * @throws ArrayIndexOutOfBoundsException if the [idx] is below zero, or above [capacity]. + * @throws IllegalStateException if the element at index [idx] is already in use. + */ + @Throws( + ArrayIndexOutOfBoundsException::class, + IllegalStateException::class, + ) + fun alloc( + idx: Int, + oldSchoolClientType: OldSchoolClientType, + info: I, + ): T { + val element = elements[idx] + check(element == null) { + "Overriding existing element: $idx" + } + val cached = queue.poll()?.get() + if (cached != null) { + onAlloc(cached, idx, oldSchoolClientType, false) + elements[idx] = cached + return cached + } + val new = + allocator( + idx, + oldSchoolClientType, + info, + ) + onAlloc(new, idx, oldSchoolClientType, true) + elements[idx] = new + return new + } + + /** + * The onAlloc function is called when a new element is allocated, necessary to clean up the + * object before it may be used. + * @param element the element being allocated + * @param idx the index of the element + * @param oldSchoolClientType the client on which the info is being allocated. + */ + protected abstract fun onAlloc( + element: T, + idx: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) + + /** + * Deallocates the element at [idx], if there is one. + * If an object was found, [net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject.onDealloc] + * function is called on it. + * This is to clean up any potential memory leaks for objects which may incur such. + * It should not reset indices and other properties, that should be left to be done + * during [alloc]. + * @param idx the index of the element to deallocate. + * @throws ArrayIndexOutOfBoundsException if the [idx] is below zero, or above [capacity]. + * @return true if the object was deallocated, false if there was nothing to deallocate. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + fun dealloc(idx: Int): Boolean { + require(idx in elements.indices) { + "Index out of boundaries: $idx, ${elements.indices}" + } + val element = + elements[idx] + ?: return false + try { + onDealloc(element) + } finally { + elements[idx] = null + } + informDeallocation(idx) + val reference = SoftReference(element, queue) + reference.enqueue() + return true + } + + /** + * Destroys the element at [idx], if there is one. + * If an object was found, [net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject.onDealloc] + * function is called on it. + * This is to clean up any potential memory leaks for objects which may incur such. + * It should not reset indices and other properties, that should be left to be done + * during [alloc]. + * Unlike the [dealloc] function, this function will not put the object back into the pool. + * This is important in case we catch an exception mid-processing, as that will immediately + * destroy the object, which technically means it could be picked up by another player right + * away in an unsafe manner. As such, these objects which threw exceptions must be garbage-collected. + * @param idx the index of the element to deallocate. + * @throws ArrayIndexOutOfBoundsException if the [idx] is below zero, or above [capacity]. + * @return true if the object was deallocated, false if there was nothing to deallocate. + */ + fun destroy(idx: Int): Boolean { + require(idx in elements.indices) { + "Index out of boundaries: $idx, ${elements.indices}" + } + val element = + elements[idx] + ?: return false + try { + onDealloc(element) + } finally { + elements[idx] = null + } + informDeallocation(idx) + return true + } + + /** + * The onDealloc function is called when an element is being deallocated. + * @param element the element being deallocated. + */ + protected abstract fun onDealloc(element: T) + + /** + * Informs all the other avatars of a given avatar being deallocated. + * This is necessary to reset our cached properties (such as the appearance cache) + * of other players. + */ + protected abstract fun informDeallocation(idx: Int) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/Infos.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/Infos.kt new file mode 100644 index 000000000..cb09ebdd5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/Infos.kt @@ -0,0 +1,166 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfo +import net.rsprot.protocol.game.outgoing.info.npcinfo.SetNpcUpdateOrigin +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfo +import net.rsprot.protocol.game.outgoing.info.util.BuildArea +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfo +import net.rsprot.protocol.game.outgoing.worldentity.SetActiveWorldV2 +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import kotlin.math.max +import kotlin.math.min + +public class Infos( + public val playerInfo: PlayerInfo, + public val npcInfo: NpcInfo, + public val worldEntityInfo: WorldEntityInfo, +) { + private var coord: CoordGrid = CoordGrid.INVALID + private var buildArea: BuildArea = BuildArea.INVALID + + /** + * Updates the current real absolute coordinate of the local player in the world. + * @param level the current height level of the player + * @param x the current absolute x coordinate of the player + * @param z the current absolute z coordinate of the player + */ + public fun updateRootCoord( + level: Int, + x: Int, + z: Int, + ) { + checkCommunicationThread() + val coordGrid = CoordGrid(level, x, z) + worldEntityInfo.updateRootCoord(coordGrid) + npcInfo.updateRootCoord(coordGrid) + playerInfo.updateRootCoord(coordGrid) + this.coord = coordGrid + } + + /** + * Updates the build area for this player. This should always perfectly correspond to + * the actual build area that is sent via REBUILD_NORMAL or REBUILD_REGION packets. + * This method takes the player's own absolute coordinates at the time of the map reload, + * and picks the coordinate as 6 zones to the south-west of them. Note that if the player + * is on a world entity at the time, it should correspond to the world entity's coordgrid + * in the root world. + * This function will furthermore cap the coordinate to not go outside the usable map space. + * @param playerAbsoluteX the absolute x coordinate in the root world + * @param playerAbsoluteZ the absolute z coordinate in the root world + */ + public fun updateRootBuildAreaCenteredOnPlayer( + playerAbsoluteX: Int, + playerAbsoluteZ: Int, + ) { + val centerZoneX = playerAbsoluteX ushr 3 + val centerZoneZ = playerAbsoluteZ ushr 3 + val swZoneX = max(0, centerZoneX - 6) + val swZoneZ = max(0, centerZoneZ - 6) + val neZoneX = min(0x7FF, centerZoneX + 6) + val neZoneZ = min(0x7FF, centerZoneZ + 6) + val widthInZones = (neZoneX - swZoneX) + 1 + val heightInZones = (neZoneZ - swZoneZ) + 1 + updateRootBuildArea(swZoneX, swZoneZ, widthInZones, heightInZones) + } + + /** + * Updates the build area for this player. This should always perfectly correspond to + * the actual build area that is sent via REBUILD_NORMAL or REBUILD_REGION packets. + * @property zoneX the south-western zone x coordinate of the build area + * @property zoneZ the south-western zone z coordinate of the build area + * @property widthInZones the build area width in zones (typically 13, meaning 104 tiles) + * @property heightInZones the build area height in zones (typically 13, meaning 104 tiles) + */ + @JvmOverloads + public fun updateRootBuildArea( + zoneX: Int, + zoneZ: Int, + widthInZones: Int = BuildArea.DEFAULT_BUILD_AREA_SIZE, + heightInZones: Int = BuildArea.DEFAULT_BUILD_AREA_SIZE, + ) { + updateRootBuildArea(BuildArea(zoneX, zoneZ, widthInZones, heightInZones)) + } + + /** + * Updates the build area for this player. This should always perfectly correspond to + * the actual build area that is sent via REBUILD_NORMAL or REBUILD_REGION packets. + * @param buildArea the build area in which everything is rendered. + */ + public fun updateRootBuildArea(buildArea: BuildArea) { + checkCommunicationThread() + worldEntityInfo.updateRootBuildArea(buildArea) + this.buildArea = buildArea + } + + /** + * Builds a data class containing all the info packets and necessary metadata in a neat package. + * Servers can simply iterate through the data here and send the packets as they are provided. + * World ids and active levels are provided alongside for easy zone synchronization. + */ + public fun getPackets(): InfoPackets { + val worldEntityInfoResult = worldEntityInfo.toPacketResult() + val playerInfoResult = playerInfo.toPacketResult() + val rootNpcInfoResult = npcInfo.toPacketResult(NpcInfo.ROOT_WORLD) + + val addedWorldIndices = worldEntityInfo.getAddedWorldEntityIndices() + val removedWorldIndices = worldEntityInfo.getRemovedWorldEntityIndices() + val allWorldIndices = worldEntityInfo.getAllWorldEntityIndices() + + val coord = this.coord + val buildArea = this.buildArea + val rootWorldCoord = worldEntityInfo.getCoordGridInRootWorld(coord) + val currentWorldEntityIndex = worldEntityInfo.getWorldEntity(coord) + + val activeRootLevel = rootWorldCoord.level + val rootWorldInfoPackets = + RootWorldInfoPackets( + activeLevel = activeRootLevel, + activeWorld = SetActiveWorldV2.getRoot(activeRootLevel), + npcUpdateOrigin = + SetNpcUpdateOrigin( + rootWorldCoord.x - (buildArea.zoneX shl 3), + rootWorldCoord.z - (buildArea.zoneZ shl 3), + ), + worldEntityInfo = worldEntityInfoResult, + playerInfo = playerInfoResult, + npcInfo = rootNpcInfoResult, + ) + + val activeWorlds = ArrayList(allWorldIndices.size) + for (worldId in allWorldIndices) { + val npcInfoResult = npcInfo.toPacketResult(worldId) + val activeLevel = + if (currentWorldEntityIndex == worldId) { + coord.level + } else { + val level = worldEntityInfo.getActiveLevel(worldId) + // Should never happen, but just in case fall back to player's own level if it does + if (level == -1) coord.level else level + } + val added = worldId in addedWorldIndices + activeWorlds.add( + WorldInfoPackets( + worldId = worldId, + activeLevel = activeLevel, + added = added, + activeWorld = + SetActiveWorldV2( + SetActiveWorldV2.DynamicWorldType( + worldId, + activeLevel, + ), + ), + npcUpdateOrigin = SetNpcUpdateOrigin.DYNAMIC, + npcInfo = npcInfoResult, + ), + ) + } + + return InfoPackets( + rootWorldInfoPackets = rootWorldInfoPackets, + removedWorldIndices = removedWorldIndices, + activeWorlds = activeWorlds, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ObserverExtendedInfoFlags.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ObserverExtendedInfoFlags.kt new file mode 100644 index 000000000..e0092f8ac --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/ObserverExtendedInfoFlags.kt @@ -0,0 +1,47 @@ +package net.rsprot.protocol.game.outgoing.info + +/** + * A data structure holding information about observer-dependent extended info flags. + * An example of this would be any extended info blocks that get written when an avatar is + * moved from low resolution to high resolution, in which case we need to synchronize any + * data that was set in the past, such as their appearance, the move speed and + * the face pathingentity status. This additionally includes any extended info blocks which + * were flagged for a specific observer alone, such as tinting utilized in Tombs of Amascut, + * where a single user will see tinting applied to all the other members of the party. + * When setting up the tinting, rather than flagging tinting on the recipient, + * we flag the observer-dependent flag on the receiver of the given extended info block. + */ +internal class ObserverExtendedInfoFlags( + capacity: Int, +) { + /** + * The observer-dependent flags. This array will not include "static" flags. + */ + private val flags: ShortArray = ShortArray(capacity) + + /** + * Resets the observer-dependent flags by filling the array with zeros. + */ + fun reset() { + flags.fill(0) + } + + /** + * Appends the given [flag] for avatar at index [index]. + * @param index the index of the recipient player or npc + * @param flag the bit flag to enable + */ + fun addFlag( + index: Int, + flag: Int, + ) { + flags[index] = (flags[index].toInt() or flag).toShort() + } + + /** + * Gets the observer-dependent flag of the avatar at index [index] + * @param index the index of the recipient player or npc + * @return the observer-dependent flag value + */ + fun getFlag(index: Int): Int = flags[index].toInt() and 0xFFFF +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/RootWorldInfoPackets.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/RootWorldInfoPackets.kt new file mode 100644 index 000000000..023c146f3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/RootWorldInfoPackets.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoPacket +import net.rsprot.protocol.game.outgoing.info.npcinfo.SetNpcUpdateOrigin +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoPacket +import net.rsprot.protocol.game.outgoing.info.util.PacketResult +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfoV7Packet +import net.rsprot.protocol.game.outgoing.worldentity.SetActiveWorldV2 + +/** + * A class that holds all the packets and properties necessary to update the root world. + * @property activeWorld the current active level. If the player is on a world entity, + * this will be equal to whatever level the world entity is projected on. Otherwise, the + * player's current level is chosen. + * @property activeWorld the set-active-world-v2 packet to inform the client which level to update + * in the root world. + * @property npcUpdateOrigin the origin offsets for npc info in the root world. These have been + * matched up with the calculations inside the packet to ensure consistency. + * @property worldEntityInfo a result of the world entity info packet call. + * @property playerInfo a result of the player info packet call. + * @property npcInfo a result of the npc info packet call. + * + * Note that if any of the three info packets are unsuccessful, the player should realistically be + * kicked offline, as there's no recovery due to it being a massive state machine. + */ +public class RootWorldInfoPackets( + public val activeLevel: Int, + public val activeWorld: SetActiveWorldV2, + public val npcUpdateOrigin: SetNpcUpdateOrigin, + public val worldEntityInfo: PacketResult, + public val playerInfo: PacketResult, + public val npcInfo: PacketResult, +) { + public val worldId: Int + get() = -1 +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/WorldInfoPackets.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/WorldInfoPackets.kt new file mode 100644 index 000000000..edb3336ec --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/WorldInfoPackets.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.info + +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoPacket +import net.rsprot.protocol.game.outgoing.info.npcinfo.SetNpcUpdateOrigin +import net.rsprot.protocol.game.outgoing.info.util.PacketResult +import net.rsprot.protocol.game.outgoing.worldentity.SetActiveWorldV2 + +/** + * A set of packets and properties for a given dynamic world. + * @property worldId the id of the world that is being updated. + * @property activeLevel the level of the world that is being updated. This will be equal + * to player's current level if this is the world on which the player currently resides, + * otherwise it is the world's own active level. + * @property added whether this world was freshly added (and [net.rsprot.protocol.game.outgoing.map.RebuildWorldEntityV2] + * should be performed on it, along with a full zone synchronization). + * @property activeWorld the active world packet necessary to inform the client of the coming update. + * @property npcUpdateOrigin the offsets for npc info packet in this world. This is a cached packet + * of the 0,0 values as that is what we calculate it against at all times. + * @property npcInfo a result of the npc info packet. It should be noted that if this result is + * unsuccessful, the player should be kicked offline, as it is not really possible to recover from it. + * Alternative would be to destroy the world itself, but that then affects everyone else nearby too. + */ +public class WorldInfoPackets( + public val worldId: Int, + public val activeLevel: Int, + public val added: Boolean, + public val activeWorld: SetActiveWorldV2, + public val npcUpdateOrigin: SetNpcUpdateOrigin, + public val npcInfo: PacketResult, +) diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/exceptions/InfoProcessException.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/exceptions/InfoProcessException.kt new file mode 100644 index 000000000..feb9b8f8c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/exceptions/InfoProcessException.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.game.outgoing.info.exceptions + +/** + * An exception that is sent whenever an info packet gets built by the server. + * This exception wraps around another exception that was generated during the processing of + * the respective info packet, allowing servers to properly observe and handle the exception. + * @param message the message associated with the exception + * @param throwable the throwable that was thrown during info processing + */ +public class InfoProcessException( + message: String, + throwable: Throwable, +) : RuntimeException(message, throwable) diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/DefaultExtendedInfoFilter.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/DefaultExtendedInfoFilter.kt new file mode 100644 index 000000000..3db4edbb4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/DefaultExtendedInfoFilter.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.game.outgoing.info.filter + +/** + * A default naive extended info filter. This filter will stop accepting + * any avatars once 30kb of data has been written in the buffer. + * As a result of this, it is guaranteed that the packet capacity will + * never be exceeded under any circumstances, as the remaining 10kb + * is more than enough to write every extended info block set to the + * theoretical maximums. + */ +public class DefaultExtendedInfoFilter : ExtendedInfoFilter { + override fun accept( + writableBytes: Int, + constantFlag: Int, + remainingAvatars: Int, + previouslyObserved: Boolean, + ): Boolean = (writableBytes - remainingAvatars) >= THEORETICAL_HIGHEST_EXTENDED_INFO_BLOCK_SIZE + + public companion object { + /** + * The theoretical highest is a rough approximation if a player had every extended + * info block flagged to the maximum, meaning 256 worst case hitmarks, headbars, spotanims, + * and so on. The real maximum comes to somewhere in the 7,000-8,000 range, + * however for some head-room and not having to recompute this all the time, + * we stick with a 10 kilobyte limitation. + * This limitation is more than enough in just about every scenario in real life. + * For the longest time, the actual total limitation was only 5 kilobytes for + * the entire player info packet, so limiting most of it to be 30kb or less + * is still a huge improvement and should cover almost all realistic scenarios. + */ + public const val THEORETICAL_HIGHEST_EXTENDED_INFO_BLOCK_SIZE: Int = 10_000 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/ExtendedInfoFilter.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/ExtendedInfoFilter.kt new file mode 100644 index 000000000..8e4771e13 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/filter/ExtendedInfoFilter.kt @@ -0,0 +1,40 @@ +package net.rsprot.protocol.game.outgoing.info.filter + +/** + * Extended info filter provides the protocol with a strategy for how to handle + * the packet capacity limitations, as it is all too easy to fly past the 40kb + * limitation in extreme scenarios and benchmarks. This interface is responsible + * for ensuring that the extended info blocks do not exceed the 40kb limitation. + * In order to achieve this, all necessary information is provided within the + * [accept] function. It should be noted that at least 1 byte of space is + * necessary per each remaining avatar at the very least, as we write the flag + * as zero in those extreme scenarios. + */ +public fun interface ExtendedInfoFilter { + /** + * Whether to accept writing the extended info blocks for the next avatar. + * @param writableBytes the amount of bytes that can still be written into the buffer + * before reaching its absolute capacity. 1 byte of space is required as a minimum + * per each [remainingAvatars]. + * @param constantFlag the bitpacked flag of all the extended info blocks flagged + * for this avatar. This function utilizes the constant flags found in + * [net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarExtendedInfo], + * rather than the client-specific variants. + * @param remainingAvatars the number of avatars for whom we need to still write + * extended info blocks. This includes the current avatar on whom we are checking + * the accept function. Per each avatar, at least one byte must be writable. + * @param previouslyObserved whether the protocol has previously observed this + * avatar. This is done by checking if our appearance cache has previously tracked + * an avatar by that index. While the exact acceptation mechanics are unknown, + * in times of high pressure, OldSchool RuneScape seems to always send extended info + * about the avatars whom you've already observed in the past. However, it is very + * strict with whom it newly accepts, often only rendering 16 or 32 players + * when there's high resolution information sent about a thousand of them. + */ + public fun accept( + writableBytes: Int, + constantFlag: Int, + remainingAvatars: Int, + previouslyObserved: Boolean, + ): Boolean +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/DeferredNpcInfoProtocolSupplier.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/DeferredNpcInfoProtocolSupplier.kt new file mode 100644 index 000000000..0af3a631e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/DeferredNpcInfoProtocolSupplier.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +/** + * A supplier for NPC info protocol to get around a circular dependency. + * Rather than re-design a significant portion of the classes around NPC info (and deal + * with backporting differences to previous revisions), it's easier to just use + * a supplier to get around the fact. + * + * @property protocol the backing protocol property, initialized lazily. + */ +public class DeferredNpcInfoProtocolSupplier { + private lateinit var protocol: NpcInfoProtocol + + /** + * Supplies the protocol value to this supplier. + * @param protocol the protocol value to assign. + */ + public fun supply(protocol: NpcInfoProtocol) { + this.protocol = protocol + } + + /** + * Gets the previously supplied protocol value, or throws an exception if it has not been supplied. + */ + public fun get(): NpcInfoProtocol = protocol +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatar.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatar.kt new file mode 100644 index 000000000..c5d654065 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatar.kt @@ -0,0 +1,477 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import net.rsprot.buffer.bitbuffer.UnsafeLongBackedBitBuf +import net.rsprot.protocol.game.outgoing.info.AvatarPriority +import net.rsprot.protocol.game.outgoing.info.npcinfo.util.NpcCellOpcodes +import net.rsprot.protocol.game.outgoing.info.util.Avatar +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.NpcAvatarDetails +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +/** + * The npc avatar class represents an NPC as shown by the client. + * This class contains all the properties necessary to put a NPC into high resolution. + * + * Npc direction table: + * ``` + * | Id | Client Angle | Direction | + * |:--:|:------------:|:----------:| + * | 0 | 768 | North-West | + * | 1 | 1024 | North | + * | 2 | 1280 | North-East | + * | 3 | 512 | West | + * | 4 | 1536 | East | + * | 5 | 256 | South-West | + * | 6 | 0 | South | + * | 7 | 1792 | South-East | + * ``` + * + * @param index the index of the npc in the world + * @param id the id of the npc in the world, limited to range of 0..16383 + * @param level the height level of the npc + * @param x the absolute x coordinate of the npc + * @param z the absolute z coordinate of the npc + * @param spawnCycle the game cycle on which the npc spawned into the world; + * for static NPCs, this would always be zero. This is only used by the C++ clients. + * @param direction the direction that the npc will face on spawn (see table above) + * @param priority the priority that the avatar will have. The default is [AvatarPriority.NORMAL]. + * If the priority is set to [AvatarPriority.LOW], the NPC will only render if there are enough + * slots leftover for the low priority group. As an example, if the low priority cap is set to 50 elements + * and there are already 50 other low priority avatars rendering to a player, this avatar will simply + * not render at all, even if there are slots leftover in the [AvatarPriority.NORMAL] group. + * For [AvatarPriority.NORMAL], both groups are accessible, although they will prefer the normal group. + * Low priority group will be used if normal group has no more free slots leftover. + * The priorities are especially useful to limit how many pets a player can see at a time. It is very common + * for servers to give everyone pets. During high population events, it is very easy to hit the 149 pet + * threshold in a local area, which could result in important NPCs, such as shopkeepers and whatnot + * from not rendering. Limiting the low priority count ensures that those arguably more important NPCs will + * still be able to render with hundreds of pets around. + * @param specific if true, the NPC will only render to players that have explicitly marked this + * NPC's index as specific-visible, anyone else will be unable to see it. If it's false, anyone can + * see the NPC regardless. + * @property extendedInfo the extended info, commonly referred to as "masks", will track everything relevant + * inside itself. Setting properties such as a spotanim would be done through this. + * The [extendedInfo] is also responsible for caching the non-temporary blocks, + * such as appearance and move speed. + * @property zoneIndexStorage the storage tracking all the allocated game NPCs based on the zones. + */ +public class NpcAvatar internal constructor( + index: Int, + id: Int, + level: Int, + x: Int, + z: Int, + spawnCycle: Int = 0, + direction: Int = 0, + priority: AvatarPriority = AvatarPriority.NORMAL, + specific: Boolean, + allocateCycle: Int, + renderDistance: Int, + public val extendedInfo: NpcAvatarExtendedInfo, + internal val zoneIndexStorage: ZoneIndexStorage, +) : Avatar { + /** + * Npc avatar details class wraps all the client properties of a NPC in its own + * data structure. + */ + internal val details: NpcAvatarDetails = + NpcAvatarDetails( + index, + id, + level, + x, + z, + spawnCycle, + direction, + priority.bitcode, + specific, + allocateCycle, + renderDistance, + ) + + private val tracker: NpcAvatarTracker = NpcAvatarTracker() + + /** + * The high resolution movement buffer, used to avoid re-calculating the movement information + * for each observer of a given NPC, in cases where there are multiple. It is additionally + * more efficient to just do a single bulk pBits() call, than to call it multiple times, which + * this accomplishes. + */ + internal var highResMovementBuffer: UnsafeLongBackedBitBuf? = null + + /** + * Adds an observer to this avatar by incrementing the observer count. + * Note that it is necessary for servers to de-register npc info when the player is logging off, + * or the protocol will run into issues on multiple levels. + */ + internal fun addObserver(index: Int) { + tracker.add(index) + } + + /** + * Removes an observer from this avatar by decrementing the observer count. + * This function must be called when a player logs off for each NPC they were observing. + */ + internal fun removeObserver(index: Int) { + // If the allocation cycle is the same as current cycle count, + // a "hotswap" has occurred. + // This means that a npc was deallocated and another allocated the same index + // in the same cycle. + // Due to the new one being allocated, the observer count is already reset + // to zero, and we cannot decrement the observer count further - it would go negative. + if (details.allocateCycle == NpcInfoProtocol.cycleCount) { + return + } + tracker.remove(index) + } + + /** + * Resets the observer count. + */ + internal fun resetObservers() { + tracker.reset() + } + + /** + * Updates the spawn direction of the NPC. + * + * Table of possible direction values: + * ``` + * | Id | Direction | Angle | + * |:--:|:----------:|:-----:| + * | 0 | North-West | 768 | + * | 1 | North | 1024 | + * | 2 | North-East | 1280 | + * | 3 | West | 512 | + * | 4 | East | 1536 | + * | 5 | South-West | 256 | + * | 6 | South | 0 | + * | 7 | South-East | 1792 | + * ``` + * + * @param direction the direction for the NPC to face. + */ + @Deprecated( + message = "Deprecated. Use setDirection(direction) for consistency.", + replaceWith = ReplaceWith("setDirection(direction)"), + ) + public fun updateDirection(direction: Int) { + setDirection(direction) + } + + /** + * Updates the spawn direction of the NPC. + * + * Table of possible direction values: + * ``` + * | Id | Direction | Angle | + * |:--:|:----------:|:-----:| + * | 0 | North-West | 768 | + * | 1 | North | 1024 | + * | 2 | North-East | 1280 | + * | 3 | West | 512 | + * | 4 | East | 1536 | + * | 5 | South-West | 256 | + * | 6 | South | 0 | + * | 7 | South-East | 1792 | + * ``` + * + * @param direction the direction for the NPC to face. + */ + public fun setDirection(direction: Int) { + checkCommunicationThread() + require(direction in 0..7) { + "Direction must be a value in range of 0..7. " + + "See the table in documentation. Value: $direction" + } + this.details.updateDirection(direction) + } + + /** + * Sets the id of the avatar - any new observers of this NPC will receive the new id. + * This should be used in tandem with the transformation extended info block. + * @param id the id of the npc to set to - any new observers will see that id instead. + */ + public fun setId(id: Int) { + checkCommunicationThread() + require(id in 0..16383) { + "Id must be a value in range of 0..16383. Value: $id" + } + this.details.id = id + } + + /** + * A helper function to teleport the NPC to a new coordinate. + * This will furthermore mark the movement type as teleport, meaning no matter what other + * coordinate changes are applied, as teleport has the highest priority, teleportation + * will be how it is rendered on the client's end. + * @param level the new height level of the NPC + * @param x the new absolute x coordinate of the NPC + * @param z the new absolute z coordinate of the NPC + * @param jump whether to "jump" the NPC to the new coordinate, or to treat it as a + * regular walk/run type movement. While this should __almost__ always be true, there are + * certain NPCs, such as Sarachnis in OldSchool, that utilize teleporting without jumping. + * This effectively makes the NPC appear as it is walking towards the destination. If the + * NPC falls visually behind, the client will begin increasing its movement speed, to a + * maximum of run speed, until it has caught up visually. + */ + public fun teleport( + level: Int, + x: Int, + z: Int, + jump: Boolean, + ) { + checkCommunicationThread() + val nextCoord = CoordGrid(level, x, z) + zoneIndexStorage.move(details.index, details.currentCoord, nextCoord) + details.currentCoord = nextCoord + details.movementType = details.movementType or (if (jump) NpcAvatarDetails.TELEJUMP else NpcAvatarDetails.TELE) + } + + /** + * Marks the NPC as moved with the crawl movement type. + * If more than one crawl/walks are sent in one cycle, it will instead be treated as run. + * If more than two crawl/walks are sent in one cycle, it will be treated as a teleport. + * @param deltaX the x coordinate delta that the NPC moved. + * @param deltaZ the z coordinate delta that the npc moved. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -1..1, + * or both are 0s. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun crawl( + deltaX: Int, + deltaZ: Int, + ) { + checkCommunicationThread() + singleStepMovement( + deltaX, + deltaZ, + NpcAvatarDetails.CRAWL, + ) + } + + /** + * Marks the NPC as moved with the walk movement type. + * If more than one crawl/walks are sent in one cycle, it will instead be treated as run. + * If more than two crawl/walks are sent in one cycle, it will be treated as a teleport. + * @param deltaX the x coordinate delta that the NPC moved. + * @param deltaZ the z coordinate delta that the npc moved. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -1..1, + * or both are 0s. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun walk( + deltaX: Int, + deltaZ: Int, + ) { + checkCommunicationThread() + singleStepMovement( + deltaX, + deltaZ, + NpcAvatarDetails.WALK, + ) + } + + /** + * Determines the movement opcode for the NPC, adjusting the NPC's underlying coordinate afterwards, + * and defines the movement speed based on previous movements in this cycle, as well as the + * [flag] requested by the movement. + * @param deltaX the x coordinate delta that the NPC moved. + * @param deltaZ the z coordinate delta that the npc moved. + * @param flag the movement speed flag, used to determine what movement speeds have been used + * in one cycle, given it is possible to move a NPC more than one in one cycle, should the + * server request it. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -1..1, + * or both are 0s. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + private fun singleStepMovement( + deltaX: Int, + deltaZ: Int, + flag: Int, + ) { + val opcode = NpcCellOpcodes.singleCellMovementOpcode(deltaX, deltaZ) + val (level, x, z) = details.currentCoord + val nextCoord = CoordGrid(level, x + deltaX, z + deltaZ) + zoneIndexStorage.move(details.index, details.currentCoord, nextCoord) + details.currentCoord = nextCoord + when (++details.stepCount) { + 1 -> { + details.firstStep = opcode + details.movementType = details.movementType or flag + } + 2 -> { + details.secondStep = opcode + details.movementType = details.movementType or NpcAvatarDetails.RUN + } + else -> { + details.movementType = details.movementType or NpcAvatarDetails.TELE + } + } + } + + /** + * Prepares the bitcodes of a NPC given the assumption it has at least one player observing it, + * and the NPC is not teleporting (or tele-jumping), as both of those cause it to be treated as + * remove + re-add client-side, meaning no normal block is used. + * While it is possible to additionally make NPC removal as part of this function, + * because part of the responsibility is at the NPC info protocol level (coordinate checks, + * state checks), it is not possible to fully cover it, so best to leave that for the protocol + * to handle. + */ + internal fun prepareBitcodes() { + val movementType = details.movementType + // If teleporting, or if there are no observers, there's no need to compute this + if (movementType and (NpcAvatarDetails.TELE or NpcAvatarDetails.TELEJUMP) != 0 || !tracker.hasObservers()) { + return + } + val buffer = UnsafeLongBackedBitBuf() + this.highResMovementBuffer = buffer + val extendedInfo = this.extendedInfo.flags != 0 + if (movementType and NpcAvatarDetails.RUN != 0) { + pRun(buffer, extendedInfo) + } else if (movementType and NpcAvatarDetails.WALK != 0) { + pWalk(buffer, extendedInfo) + } else if (movementType and NpcAvatarDetails.CRAWL != 0) { + pCrawl(buffer, extendedInfo) + } else if (extendedInfo) { + pExtendedInfo(buffer) + } else { + pNoUpdate(buffer) + } + } + + /** + * Informs the client that there will be no movement or extended info update for this NPC. + * @param buffer the pre-computed buffer into which to write the bitcodes. + */ + private fun pNoUpdate(buffer: UnsafeLongBackedBitBuf) { + buffer.pBits(1, 0) + } + + /** + * Informs the client that there is no movement occurring for this NPC, but it does have + * extended info blocks encoded. + * @param buffer the pre-computed buffer into which to write the bitcodes. + */ + private fun pExtendedInfo(buffer: UnsafeLongBackedBitBuf) { + buffer.pBits(1, 1) + buffer.pBits(2, 0) + } + + /** + * Informs the client that there is a crawl-speed movement occurring for this NPC. + * @param buffer the pre-computed buffer into which to write the bitcodes. + * @param extendedInfo whether this NPC additionally has extended info updates coming. + */ + private fun pCrawl( + buffer: UnsafeLongBackedBitBuf, + extendedInfo: Boolean, + ) { + buffer.pBits(1, 1) + buffer.pBits(2, 2) + buffer.pBits(1, 0) + buffer.pBits(3, details.firstStep) + buffer.pBits(1, if (extendedInfo) 1 else 0) + } + + /** + * Informs the client that there is a walk-speed movement occurring for this NPC. + * @param buffer the pre-computed buffer into which to write the bitcodes. + * @param extendedInfo whether this NPC additionally has extended info updates coming. + */ + private fun pWalk( + buffer: UnsafeLongBackedBitBuf, + extendedInfo: Boolean, + ) { + buffer.pBits(1, 1) + buffer.pBits(2, 1) + buffer.pBits(3, details.firstStep) + buffer.pBits(1, if (extendedInfo) 1 else 0) + } + + /** + * Informs the client that there is a run-speed movement occurring for this NPC. + * @param buffer the pre-computed buffer into which to write the bitcodes. + * @param extendedInfo whether this NPC additionally has extended info updates coming. + */ + private fun pRun( + buffer: UnsafeLongBackedBitBuf, + extendedInfo: Boolean, + ) { + buffer.pBits(1, 1) + buffer.pBits(2, 2) + buffer.pBits(1, 1) + buffer.pBits(3, details.firstStep) + buffer.pBits(3, details.secondStep) + buffer.pBits(1, if (extendedInfo) 1 else 0) + } + + /** + * The current height level of this avatar. + */ + public fun level(): Int = details.currentCoord.level + + /** + * The current absolute x coordinate of this avatar. + */ + public fun x(): Int = details.currentCoord.x + + /** + * The current absolute z coordinate of this avatar. + */ + public fun z(): Int = details.currentCoord.z + + /** + * Sets this avatar inaccessible, meaning no player can observe this NPC, + * but they are still in the world. This is how NPCs in the 'dead' state + * will be handled. + * @param inaccessible whether the npc is inaccessible to all players (not rendered) + */ + public fun setInaccessible(inaccessible: Boolean) { + checkCommunicationThread() + details.inaccessible = inaccessible + } + + /** + * Checks whether a npc is actively observed by at least one player. + * @return true if the NPC has at least one player currently observing it via + * NPC info, false otherwise. + */ + public fun isActive(): Boolean = tracker.hasObservers() + + /** + * Checks the number of players that are currently observing this NPC avatar. + * @return the number of players that are observing this avatar. + */ + public fun getObserverCount(): Int = tracker.getObserverCount() + + /** + * Gets a set of all the indexes of the players that are observing this NPC. + * + * It is important to note that the collection is re-used across cycles. + * If the collection is intended to be stored for long-term usage, it should be + * copied to a new data set, or re-called each cycle. Trying to access the iterator + * across game cycles will result in a [ConcurrentModificationException]. + * + * @return a set of all the player indices observing this NPC. + */ + public fun getObservingPlayerIndices(): Set = tracker.getCachedSet() + + override fun postUpdate() { + details.stepCount = 0 + details.firstStep = -1 + details.secondStep = -1 + details.movementType = 0 + extendedInfo.postUpdate() + } + + override fun toString(): String = + "NpcAvatar(" + + "extendedInfo=$extendedInfo, " + + "details=$details, " + + "tracker=$tracker, " + + "highResMovementBuffer=$highResMovementBuffer" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExceptionHandler.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExceptionHandler.kt new file mode 100644 index 000000000..36dc5002d --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExceptionHandler.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import java.lang.Exception + +/** + * An exception handler for npc avatar processing. + * This is necessary as we might run into hiccups during computations of a specific npc, + * in which case we need to propagate the exceptions to the server, which will ideally remove said npcs + * from the world as a result of it. + */ +public fun interface NpcAvatarExceptionHandler { + /** + * This function is triggered whenever there's an exception caught during npc + * avatar processing. + * @param index the index of the npc that had an exception during its processing. + * @param exception the exception that was caught during a npc's avatar processing + */ + public fun exceptionCaught( + index: Int, + exception: Exception, + ) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfo.kt new file mode 100644 index 000000000..2ca0efcb0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfo.kt @@ -0,0 +1,1611 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcExtendedInfoEncoders +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BaseAnimationSet +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.CombatLevelChange +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadIconCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.TypeCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.precompute +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HeadBar +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HitMark +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.SpotAnim + +public typealias NpcAvatarExtendedInfoWriter = + AvatarExtendedInfoWriter + +/** + * Npc avatar extended info is a data structure used to keep track of all the extended info + * properties of the given avatar. + * @property avatarIndex the index of the avatar npc + * @property filter the filter used to ensure that the buffer does not exceed the 40kb limit. + * @param extendedInfoWriters the list of client-specific extended info writers. + * @property allocator the byte buffer allocator used to pre-compute extended info blocks. + * @property huffmanCodec the huffman codec is used to compress chat messages, though + * none are used for NPCs, the writer function still expects it. + */ +@Suppress("DuplicatedCode") +public class NpcAvatarExtendedInfo( + private var avatarIndex: Int, + private val filter: ExtendedInfoFilter, + extendedInfoWriters: List, + private val allocator: ByteBufAllocator, + private val huffmanCodec: HuffmanCodecProvider, +) { + /** + * The extended info blocks enabled on this NPC in a given cycle. + */ + internal var flags: Int = 0 + + /** + * Extended info blocks used to transmit changes to the client, + * wrapped in its own class as we must pass this onto the client-specific + * implementations. + */ + private val blocks: NpcAvatarExtendedInfoBlocks = NpcAvatarExtendedInfoBlocks(extendedInfoWriters) + + /** + * The client-specific extended info writers, indexed by the respective [OldSchoolClientType]'s id. + * All clients in use must be registered, or an exception will occur during player info encoding. + */ + private val writers: Array = + buildClientWriterArray(extendedInfoWriters) + + /** + * Sets the sequence for this avatar to play. + * @param id the id of the sequence to play, or -1 to stop playing current sequence. + * @param delay the delay in client cycles (20ms/cc) until the avatar starts playing this sequence. + */ + public fun setSequence( + id: Int, + delay: Int, + ) { + checkCommunicationThread() + verify { + require(id == -1 || id in UNSIGNED_SHORT_RANGE) { + "Unexpected sequence id: $id, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + require(delay in UNSIGNED_SHORT_RANGE) { + "Unexpected sequence delay: $delay, expected range: $UNSIGNED_SHORT_RANGE" + } + } + blocks.sequence.id = id.toUShort() + blocks.sequence.delay = delay.toUShort() + flags = flags or SEQUENCE + } + + /** + * Sets the face-locking onto the avatar with index [index]. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * In order to stop facing an entity, set the index value to -1. + * @param index the index of the target to face-lock onto (read above) + */ + public fun setFacePathingEntity(index: Int) { + checkCommunicationThread() + verify { + require(index == -1 || index in 0..0x107FF) { + "Unexpected pathing entity index: $index, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + blocks.facePathingEntity.index = index + flags = flags or FACE_PATHINGENTITY + } + + /** + * Sets the overhead chat of this avatar. + * @param text the text to render overhead. + */ + public fun setSay(text: String) { + checkCommunicationThread() + verify { + require(text.length <= 256) { + "Unexpected say input; expected value 256 characters or less, " + + "input len: ${text.length}, input: $text" + } + } + blocks.say.text = text + flags = flags or SAY + } + + /** + * Sets an exact movement for this avatar. It should be noted + * that this is done in conjunction with actual movement, as the + * exact move extended info block is only responsible for visualizing + * precise movement, and will synchronize to the real coordinate once + * the exact movement has finished. + * + * @param deltaX1 the coordinate delta between the current absolute + * x coordinate and where the avatar is going. + * @param deltaZ1 the coordinate delta between the current absolute + * z coordinate and where the avatar is going. + * @param delay1 how many client cycles (20ms/cc) until the avatar arrives + * at x/z 1 coordinate. + * @param deltaX2 the coordinate delta between the current absolute + * x coordinate and where the avatar is going. + * @param deltaZ2 the coordinate delta between the current absolute + * z coordinate and where the avatar is going. + * @param delay2 how many client cycles (20ms/cc) until the avatar arrives + * at x/z 2 coordinate. + * @param angle the angle the avatar will be facing throughout the exact movement, + * with 0 implying south, 512 west, 1024 north and 1536 east; interpolate + * between to get finer directions. + */ + public fun setExactMove( + deltaX1: Int, + deltaZ1: Int, + delay1: Int, + deltaX2: Int, + deltaZ2: Int, + delay2: Int, + angle: Int, + ) { + checkCommunicationThread() + verify { + require(delay1 >= 0) { + "First delay cannot be negative: $delay1" + } + require(delay2 >= 0) { + "Second delay cannot be negative: $delay2" + } + require(angle in 0..2047) { + "Unexpected angle value: $angle, expected range: 0..2047" + } + require(deltaX1 in SIGNED_BYTE_RANGE) { + "Unexpected deltaX1: $deltaX1, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaZ1 in SIGNED_BYTE_RANGE) { + "Unexpected deltaZ1: $deltaZ1, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaX2 in SIGNED_BYTE_RANGE) { + "Unexpected deltaX1: $deltaX2, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaZ2 in SIGNED_BYTE_RANGE) { + "Unexpected deltaZ1: $deltaZ2, expected range: $SIGNED_BYTE_RANGE" + } + } + blocks.exactMove.deltaX1 = deltaX1.toUByte() + blocks.exactMove.deltaZ1 = deltaZ1.toUByte() + blocks.exactMove.delay1 = delay1.toUShort() + blocks.exactMove.deltaX2 = deltaX2.toUByte() + blocks.exactMove.deltaZ2 = deltaZ2.toUByte() + blocks.exactMove.delay2 = delay2.toUShort() + blocks.exactMove.direction = angle.toUShort() + flags = flags or EXACT_MOVE + } + + /** + * Sets the spotanim in slot [slot], overriding any previous spotanim + * in that slot in doing so. + * @param slot the slot of the spotanim. + * @param id the id of the spotanim. + * @param delay the delay in client cycles (20ms/cc) until the given spotanim begins rendering. + * @param height the height at which to render the spotanim. + */ + public fun setSpotAnim( + slot: Int, + id: Int, + delay: Int, + height: Int, + ) { + checkCommunicationThread() + verify { + require(slot in 0..= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(sourceType in HIT_TYPE_RANGE) { + "Unexpected sourceType: $sourceType, expected range $HIT_TYPE_RANGE" + } + require(otherType in HIT_TYPE_RANGE) { + "Unexpected otherType: $otherType, expected range $HIT_TYPE_RANGE" + } + require(value in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected value: $value, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += + HitMark( + sourceIndex, + sourceType.toUShort(), + sourceType.toUShort(), + otherType.toUShort(), + value.toUShort(), + delay.toUShort(), + ) + flags = flags or HITS + } + + /** + * Removes the oldest currently showing hitmark on this avatar, + * if one exists. + * @param delay the delay in client cycles (20ms/cc) until the hitmark is removed. + */ + public fun removeHitMark(delay: Int = 0) { + checkCommunicationThread() + if (blocks.hit.hitMarkList.size >= 0xFF) { + return + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += HitMark(0x7FFEu, delay.toUShort()) + flags = flags or HITS + } + + /** + * Adds a simple hitmark on this avatar. + * @param sourceIndex the index of the character that dealt the hit. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for tinting purposes, as both the player who dealt + * the hit, and the recipient will see a tinted variant. + * Everyone else, however, will see a regular darkened hit mark. + * @param sourceType the multi hitmark id that supports tinted and darkened variants. + * If the value is -1, the hitmark will not render to the player with the source index, + * only everyone else. + * @param otherType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * If the hitmark should only render to the local player, set the [otherType] + * value to -1, forcing it to only render to the recipient (and in the case of + * a [sourceIndex] being defined, the one who dealt the hit) + * @param value the value to show over the hitmark. + * @param selfSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. + * @param otherSoakType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * Unlike the [otherType], this does not support -1, as it is not possible to show partial + * soaked hitmarks. + * @param delay the delay in client cycles (20ms/cc) until the hitmark renders. + */ + @JvmOverloads + public fun addSoakedHitMark( + sourceIndex: Int, + sourceType: Int, + otherType: Int = sourceType, + value: Int, + selfSoakType: Int, + otherSoakType: Int = selfSoakType, + soakValue: Int, + delay: Int = 0, + ) { + checkCommunicationThread() + if (blocks.hit.hitMarkList.size >= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(sourceType in HIT_TYPE_RANGE) { + "Unexpected sourceType: $sourceType, expected range $HIT_TYPE_RANGE" + } + require(otherType in HIT_TYPE_RANGE) { + "Unexpected otherType: $otherType, expected range $HIT_TYPE_RANGE" + } + require(value in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected value: $value, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(selfSoakType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected selfSoakType: $selfSoakType, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(otherSoakType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected otherSoakType: $otherSoakType, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(soakValue in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected soakValue: $soakValue, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += + HitMark( + sourceIndex, + sourceType.toUShort(), + sourceType.toUShort(), + otherType.toUShort(), + value.toUShort(), + selfSoakType.toUShort(), + selfSoakType.toUShort(), + otherSoakType.toUShort(), + soakValue.toUShort(), + delay.toUShort(), + ) + flags = flags or HITS + } + + /** + * Adds a headbar onto the avatar. + * If a headbar by the same id already exists, updates the status of the old one. + * Up to four distinct headbars can be rendered simultaneously. + * + * @param sourceIndex the index of the entity that dealt the hit that resulted in this headbar. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for rendering purposes, as both the player who dealt + * the hit, and the recipient will see the [sourceType] variant, and everyone else + * will see the [otherType] variant, which, if set to -1 will be skipped altogether. + * @param sourceType the id of the headbar to render to the player with the source index. + * @param otherType the id of the headbar to render to everyone that doesn't fit the [sourceType] + * criteria. If set to -1, the headbar will not be rendered to these individuals. + * @param startFill the number of pixels to render of this headbar at in the start. + * The number of pixels that a headbar supports is defined in its respective headbar config. + * @param endFill the number of pixels to render of this headbar at in the end, + * if a [startTime] and [endTime] are defined. + * @param startTime the delay in client cycles (20ms/cc) until the headbar renders at [startFill] + * @param endTime the delay in client cycles (20ms/cc) until the headbar arrives at [endFill]. + */ + @JvmOverloads + public fun addHeadBar( + sourceIndex: Int, + sourceType: Int, + otherType: Int = sourceType, + startFill: Int, + endFill: Int = startFill, + startTime: Int = 0, + endTime: Int = 0, + ) { + checkCommunicationThread() + if (blocks.hit.headBarList.size >= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + // Fills are transmitted via a byte, so they would not crash + require(startFill in UNSIGNED_BYTE_RANGE) { + "Unexpected startFill: $startFill, expected range $UNSIGNED_BYTE_RANGE" + } + require(endFill in UNSIGNED_BYTE_RANGE) { + "Unexpected endFill: $endFill, expected range $UNSIGNED_BYTE_RANGE" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(sourceType == -1 || sourceType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected source type: $sourceType, expected value -1 or in range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(otherType == -1 || otherType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected other type: $otherType, expected value -1 or in range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(startTime in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected startTime: $startTime, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(endTime in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected endTime: $endTime, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.headBarList += + HeadBar( + sourceIndex, + sourceType.toUShort(), + otherType.toUShort(), + startFill.toUByte(), + endFill.toUByte(), + startTime.toUShort(), + endTime.toUShort(), + ) + flags = flags or HITS + } + + /** + * Removes a headbar on this avatar by the id of [id], if one renders. + * @param id the id of the head bar to remove. + */ + public fun removeHeadBar(id: Int) { + checkCommunicationThread() + addHeadBar( + -1, + id, + startFill = 0, + endTime = HeadBar.REMOVED.toInt(), + ) + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + */ + @Deprecated( + message = "Deprecated. Use setTinting(startTime, endTime, hue, saturation, lightness, weight) for consistency.", + replaceWith = ReplaceWith("setTinting(startTime, endTime, hue, saturation, lightness, weight)"), + ) + public fun tinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + ) { + setTinting( + startTime, + endTime, + hue, + saturation, + lightness, + weight, + ) + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + */ + public fun setTinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + ) { + checkCommunicationThread() + verify { + require(startTime in UNSIGNED_SHORT_RANGE) { + "Unexpected startTime: $startTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime in UNSIGNED_SHORT_RANGE) { + "Unexpected endTime: $endTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime >= startTime) { + "End time should be equal to or greater than start time: $endTime > $startTime" + } + require(hue in UNSIGNED_BYTE_RANGE) { + "Unexpected hue: $hue, expected range $UNSIGNED_BYTE_RANGE" + } + require(saturation in UNSIGNED_BYTE_RANGE) { + "Unexpected saturation: $saturation, expected range $UNSIGNED_BYTE_RANGE" + } + require(lightness in UNSIGNED_BYTE_RANGE) { + "Unexpected lightness: $lightness, expected range $UNSIGNED_BYTE_RANGE" + } + require(weight in UNSIGNED_BYTE_RANGE) { + "Unexpected weight: $weight, expected range $UNSIGNED_BYTE_RANGE" + } + } + val tint = blocks.tinting.global + tint.start = startTime.toUShort() + tint.end = endTime.toUShort() + tint.hue = hue.toUByte() + tint.saturation = saturation.toUByte() + tint.lightness = lightness.toUByte() + tint.weight = weight.toUByte() + flags = flags or TINTING + } + + /** + * Sets the angle for this avatar to face. + * @param angle the angle to face, value range is 0..<2048, + * with 0 implying south, 512 west, 1024 north and 1536 east; interpolate + * between to get finer directions. + * @param instant whether to turn towards the angle instantly without any turn anim, + * or gradually. The instant property is typically used when spawning in NPCs; + * While the low to high resolution change does support a direction, it only supports + * in increments of 45 degrees - so utilizing this extended info blocks allows for + * more precise control over it. + */ + @JvmOverloads + public fun setFaceAngle( + angle: Int, + instant: Boolean = false, + ) { + checkCommunicationThread() + verify { + require(angle in 0..2047) { + "Unexpected angle: $angle, expected range: 0-2047" + } + } + val faceAngle = blocks.faceAngle + faceAngle.angle = angle.toUShort() + faceAngle.instant = instant + flags = flags or FACE_ANGLE + } + + /** + * Transforms this NPC into the [id] provided. + * It should be noted that this extended info block is transient and only applies to one cycle. + * The server is expected to additionally change the id of the avatar itself, otherwise + * any new observers will get the old variant. + * + * Additionally, note that in order to reset the NPC back to the original variant, the server + * must transform the NPC to the original id. RSProt does not track the original id internally. + * @param id the new id of the npc to transform to. + */ + @Deprecated( + message = "Deprecated. Use setTransmogrification(id) for consistency.", + replaceWith = ReplaceWith("setTransmogrification(id)"), + ) + public fun transformation(id: Int) { + setTransmogrification(id) + } + + /** + * Transforms this NPC into the [id] provided. + * It should be noted that this extended info block is transient and only applies to one cycle. + * The server is expected to additionally change the id of the avatar itself via [NpcAvatar.setId], + * otherwise any new observers will get the old variant. + * + * Additionally, note that in order to reset the NPC back to the original variant, the server + * must transform the NPC to the original id. RSProt does not track the original id internally. + * @param id the new id of the npc to transform to. + */ + public fun setTransmogrification(id: Int) { + checkCommunicationThread() + verify { + require(id in UNSIGNED_SHORT_RANGE) { + "Unexpected id: $id, expected in range: $UNSIGNED_SHORT_RANGE" + } + } + blocks.transformation.id = id.toUShort() + flags = flags or TRANSFORMATION + } + + /** + * Overrides the combat level of this NPC with the provided level. + * @param level the combat leve to render, or -1 to remove the combat level override. + */ + @Deprecated( + message = "Deprecated. Use setCombatLevelChange(level) for consistency.", + replaceWith = ReplaceWith("setCombatLevelChange(level)"), + ) + public fun combatLevelChange(level: Int) { + setCombatLevelChange(level) + } + + /** + * Overrides the combat level of this NPC with the provided level. + * @param level the combat leve to render, or -1 to remove the combat level override. + */ + public fun setCombatLevelChange(level: Int) { + checkCommunicationThread() + blocks.combatLevelChange.level = level + flags = flags or LEVEL_CHANGE + } + + /** + * Overrides the name of this NPC with the provided [name]. + * @param name the name to override with, or null to reset an existing override. + */ + @Deprecated( + message = "Deprecated. Use setNameChange(name) for consistency.", + replaceWith = ReplaceWith("setNameChange(name)"), + ) + public fun nameChange(name: String?) { + setNameChange(name) + } + + /** + * Overrides the name of this NPC with the provided [name]. + * @param name the name to override with, or null to reset an existing override. + */ + public fun setNameChange(name: String?) { + checkCommunicationThread() + blocks.nameChange.name = name + flags = flags or NAME_CHANGE + } + + /** + * Sets the visible ops flag of this NPC to the provided value. + * @param flag the bit flag to set. Only the 5 lowest bits are used, + * and an enabled bit implies the option at that index should render. + * Note that this extended info block is not transient and will be transmitted to + * future players as well. + */ + @Suppress("MemberVisibilityCanBePrivate") + @Deprecated( + message = "Deprecated. Use setVisibleOps(flag) for consistency.", + replaceWith = ReplaceWith("setVisibleOps(flag)"), + ) + public fun visibleOps(flag: Int) { + setVisibleOps(flag) + } + + /** + * Sets the visible ops flag of this NPC to the provided value. + * @param flag the bit flag to set. Only the 5 lowest bits are used, + * and an enabled bit implies the option at that index should render. + * Note that this extended info block is not transient and will be transmitted to + * future players as well. + */ + @Deprecated( + message = "Deprecated. Use setVisibleOps() with [net.rsprot.protocol.game.outgoing.util.OpFlags].", + replaceWith = ReplaceWith("setVisibleOps(flag.toByte())"), + ) + @Suppress("MemberVisibilityCanBePrivate") + public fun setVisibleOps(flag: Int) { + checkCommunicationThread() + setVisibleOps(flag.toByte()) + } + + /** + * Marks the provided right-click options as visible or invisible. + * @param op1 whether to render op1 + * @param op2 whether to render op2 + * @param op3 whether to render op3 + * @param op4 whether to render op4 + * @param op5 whether to render op5 + */ + @Deprecated( + message = "Deprecated. Use setVisibleOps(op1, op2, op3, op4, op5) for consistency.", + replaceWith = ReplaceWith("setVisibleOps(op1, op2, op3, op4, op5)"), + ) + public fun visibleOps( + op1: Boolean, + op2: Boolean, + op3: Boolean, + op4: Boolean, + op5: Boolean, + ) { + setVisibleOps(op1, op2, op3, op4, op5) + } + + /** + * Marks the provided right-click options as visible or invisible. + * @param op1 whether to render op1 + * @param op2 whether to render op2 + * @param op3 whether to render op3 + * @param op4 whether to render op4 + * @param op5 whether to render op5 + */ + @Deprecated( + message = "Deprecated. Use setVisibleOps() with [net.rsprot.protocol.game.outgoing.util.OpFlags].", + replaceWith = ReplaceWith("setVisibleOps(OpFlags.ofOps(op1, op2, op3, op4, op5))"), + ) + public fun setVisibleOps( + op1: Boolean, + op2: Boolean, + op3: Boolean, + op4: Boolean, + op5: Boolean, + ) { + var flag = 0 + if (op1) flag = flag or 0x1 + if (op2) flag = flag or 0x2 + if (op3) flag = flag or 0x4 + if (op4) flag = flag or 0x8 + if (op5) flag = flag or 0x10 + setVisibleOps(flag) + } + + /** + * Sets all the right-click options invisible on this NPC. + */ + @Deprecated( + message = "Deprecated. Use setAllOpsInvisible() for consistency.", + replaceWith = ReplaceWith("setAllOpsInvisible()"), + ) + public fun allOpsInvisible() { + setAllOpsInvisible() + } + + /** + * Sets all the right-click options invisible on this NPC. + */ + @Deprecated( + message = "Deprecated. Use setVisibleOps() with [net.rsprot.protocol.game.outgoing.util.OpFlags].", + replaceWith = ReplaceWith("setVisibleOps(OpFlags.NONE_SHOWN)"), + ) + public fun setAllOpsInvisible() { + setVisibleOps(0) + } + + /** + * Sets all the right-click options as visible on this NPC. + */ + @Deprecated( + message = "Deprecated. Use setAllOpsVisible() for consistency.", + replaceWith = ReplaceWith("setAllOpsVisible()"), + ) + public fun allOpsVisible() { + setAllOpsVisible() + } + + /** + * Sets all the right-click options as visible on this NPC. + */ + @Deprecated( + message = "Deprecated. Use setVisibleOps() with [net.rsprot.protocol.game.outgoing.util.OpFlags].", + replaceWith = ReplaceWith("setVisibleOps(OpFlags.ALL_SHOWN)"), + ) + public fun setAllOpsVisible() { + setVisibleOps(0b11111) + } + + /** + * Sets the visible ops flag of this NPC to the provided value. + * @param flag the bit flag to set. Only the 5 lowest bits are used, + * and an enabled bit implies the option at that index should render. + * Note that this extended info block is not transient and will be transmitted to + * future players as well. + * + * Use [net.rsprot.protocol.game.outgoing.util.OpFlags] class to build the flag. + */ + public fun setVisibleOps(flag: Byte) { + checkCommunicationThread() + blocks.visibleOps.ops = flag.toUByte() + flags = flags or OPS + } + + /** + * Sets the base animation set of this NPC with the provided values. + * If the value is equal to [Int.MIN_VALUE], the animation will not be overwritten. + * Only the 16 lowest bits of the animation ids are used. + * @param turnLeftAnim the animation used when the NPC turns to the left + * @param turnRightAnim the animation used when the NPC turns to the right + * @param walkAnim the animation used when the NPC walks forward + * @param walkAnimLeft the animation used when the NPC walks to the left + * @param walkAnimRight the animation used when the NPC walks to the right + * @param walkAnimBack the animation used when the NPC walks backwards + * @param runAnim the animation used when the NPC runs forward + * @param runAnimLeft the animation used when the NPC runs to the left + * @param runAnimRight the animation used when the NPC runs to the right + * @param runAnimBack the animation used when the NPC runs backwards + * @param crawlAnim the animation used when the NPC crawls forward + * @param crawlAnimLeft the animation used when the NPC crawls to the left + * @param crawlAnimRight the animation used when the NPC crawls to the right + * @param crawlAnimBack the animation used when the NPC crawls backwards + * @param readyAnim the default stance animation of this NPC when it is not moving + */ + @JvmSynthetic + @Deprecated( + message = + "Deprecated. Use setBaseAnimationSet(turnLeftAnim, turnRightAnim, " + + "walkAnim, walkAnimBack, walkAnimLeft, walkAnimRight, " + + "runAnim, runAnimBack, runAnimLeft, runAnimRight, " + + "crawlAnim, crawlAnimBack, crawlAnimLeft, crawlAnimRight, readyAnim) for consistency.", + replaceWith = + ReplaceWith( + "setBaseAnimationSet(turnLeftAnim, turnRightAnim, " + + "walkAnim, walkAnimBack, walkAnimLeft, walkAnimRight, " + + "runAnim, runAnimBack, runAnimLeft, runAnimRight, " + + "crawlAnim, crawlAnimBack, crawlAnimLeft, crawlAnimRight, readyAnim)", + ), + ) + public fun baseAnimationSet( + turnLeftAnim: Int = Int.MIN_VALUE, + turnRightAnim: Int = Int.MIN_VALUE, + walkAnim: Int = Int.MIN_VALUE, + walkAnimBack: Int = Int.MIN_VALUE, + walkAnimLeft: Int = Int.MIN_VALUE, + walkAnimRight: Int = Int.MIN_VALUE, + runAnim: Int = Int.MIN_VALUE, + runAnimBack: Int = Int.MIN_VALUE, + runAnimLeft: Int = Int.MIN_VALUE, + runAnimRight: Int = Int.MIN_VALUE, + crawlAnim: Int = Int.MIN_VALUE, + crawlAnimBack: Int = Int.MIN_VALUE, + crawlAnimLeft: Int = Int.MIN_VALUE, + crawlAnimRight: Int = Int.MIN_VALUE, + readyAnim: Int = Int.MIN_VALUE, + ) { + setBaseAnimationSet( + turnLeftAnim, + turnRightAnim, + walkAnim, + walkAnimBack, + walkAnimLeft, + walkAnimRight, + runAnim, + runAnimBack, + runAnimLeft, + runAnimRight, + crawlAnim, + crawlAnimBack, + crawlAnimLeft, + crawlAnimRight, + readyAnim, + ) + } + + /** + * Sets the base animation set of this NPC with the provided values. + * If the value is equal to [Int.MIN_VALUE], the animation will not be overwritten. + * Only the 16 lowest bits of the animation ids are used. + * @param turnLeftAnim the animation used when the NPC turns to the left + * @param turnRightAnim the animation used when the NPC turns to the right + * @param walkAnim the animation used when the NPC walks forward + * @param walkAnimLeft the animation used when the NPC walks to the left + * @param walkAnimRight the animation used when the NPC walks to the right + * @param walkAnimBack the animation used when the NPC walks backwards + * @param runAnim the animation used when the NPC runs forward + * @param runAnimLeft the animation used when the NPC runs to the left + * @param runAnimRight the animation used when the NPC runs to the right + * @param runAnimBack the animation used when the NPC runs backwards + * @param crawlAnim the animation used when the NPC crawls forward + * @param crawlAnimLeft the animation used when the NPC crawls to the left + * @param crawlAnimRight the animation used when the NPC crawls to the right + * @param crawlAnimBack the animation used when the NPC crawls backwards + * @param readyAnim the default stance animation of this NPC when it is not moving + */ + @JvmSynthetic + public fun setBaseAnimationSet( + turnLeftAnim: Int = Int.MIN_VALUE, + turnRightAnim: Int = Int.MIN_VALUE, + walkAnim: Int = Int.MIN_VALUE, + walkAnimBack: Int = Int.MIN_VALUE, + walkAnimLeft: Int = Int.MIN_VALUE, + walkAnimRight: Int = Int.MIN_VALUE, + runAnim: Int = Int.MIN_VALUE, + runAnimBack: Int = Int.MIN_VALUE, + runAnimLeft: Int = Int.MIN_VALUE, + runAnimRight: Int = Int.MIN_VALUE, + crawlAnim: Int = Int.MIN_VALUE, + crawlAnimBack: Int = Int.MIN_VALUE, + crawlAnimLeft: Int = Int.MIN_VALUE, + crawlAnimRight: Int = Int.MIN_VALUE, + readyAnim: Int = Int.MIN_VALUE, + ) { + checkCommunicationThread() + val bas = blocks.baseAnimationSet + var flag = bas.overrides + if (turnLeftAnim != Int.MIN_VALUE) { + bas.turnLeftAnim = turnLeftAnim.toUShort() + flag = flag or BaseAnimationSet.TURN_LEFT_ANIM_FLAG + } + if (turnRightAnim != Int.MIN_VALUE) { + bas.turnRightAnim = turnRightAnim.toUShort() + flag = flag or BaseAnimationSet.TURN_RIGHT_ANIM_FLAG + } + if (walkAnim != Int.MIN_VALUE) { + bas.walkAnim = walkAnim.toUShort() + flag = flag or BaseAnimationSet.WALK_ANIM_FLAG + } + if (walkAnimBack != Int.MIN_VALUE) { + bas.walkAnimBack = walkAnimBack.toUShort() + flag = flag or BaseAnimationSet.WALK_ANIM_BACK_FLAG + } + if (walkAnimLeft != Int.MIN_VALUE) { + bas.walkAnimLeft = walkAnimLeft.toUShort() + flag = flag or BaseAnimationSet.WALK_ANIM_LEFT_FLAG + } + if (walkAnimRight != Int.MIN_VALUE) { + bas.walkAnimRight = walkAnimRight.toUShort() + flag = flag or BaseAnimationSet.WALK_ANIM_RIGHT_FLAG + } + if (runAnim != Int.MIN_VALUE) { + bas.runAnim = runAnim.toUShort() + flag = flag or BaseAnimationSet.RUN_ANIM_FLAG + } + if (runAnimBack != Int.MIN_VALUE) { + bas.runAnimBack = runAnimBack.toUShort() + flag = flag or BaseAnimationSet.RUN_ANIM_BACK_FLAG + } + if (runAnimLeft != Int.MIN_VALUE) { + bas.runAnimLeft = runAnimLeft.toUShort() + flag = flag or BaseAnimationSet.RUN_ANIM_LEFT_FLAG + } + if (runAnimRight != Int.MIN_VALUE) { + bas.runAnimRight = runAnimRight.toUShort() + flag = flag or BaseAnimationSet.RUN_ANIM_RIGHT_FLAG + } + if (crawlAnim != Int.MIN_VALUE) { + bas.crawlAnim = crawlAnim.toUShort() + flag = flag or BaseAnimationSet.CRAWL_ANIM_FLAG + } + if (crawlAnimBack != Int.MIN_VALUE) { + bas.crawlAnimBack = crawlAnimBack.toUShort() + flag = flag or BaseAnimationSet.CRAWL_ANIM_BACK_FLAG + } + if (crawlAnimLeft != Int.MIN_VALUE) { + bas.crawlAnimLeft = crawlAnimLeft.toUShort() + flag = flag or BaseAnimationSet.CRAWL_ANIM_LEFT_FLAG + } + if (crawlAnimRight != Int.MIN_VALUE) { + bas.crawlAnimRight = crawlAnimRight.toUShort() + flag = flag or BaseAnimationSet.CRAWL_ANIM_RIGHT_FLAG + } + if (readyAnim != Int.MIN_VALUE) { + bas.readyAnim = readyAnim.toUShort() + flag = flag or BaseAnimationSet.READY_ANIM_FLAG + } + bas.overrides = flag + flags = flags or BAS_CHANGE + } + + /** + * Resets any cached base animation set values, making the NPC identical to that + * from the cache as far as base animations go. + */ + public fun resetBaseAnimationSet() { + checkCommunicationThread() + val bas = blocks.baseAnimationSet + if (bas.overrides == 0) return + bas.overrides = 0 + flags = flags or BAS_CHANGE + } + + /** + * Sets the ready animation of this NPC to the provided [id]. + * @param id the ready animation id + */ + public fun setReadyAnim(id: Int) { + setBaseAnimationSet(readyAnim = id) + } + + /** + * Sets the turn left and turn right animations of this NPC. + * @param turnLeftAnim the animation used when the NPC turns to the left, or null if + * turn left animation should be skipped + * @param turnRightAnim the animation used when the NPC turns to the right, or null if + * turn right animation should be skipped. + */ + public fun setTurnAnims( + turnLeftAnim: Int?, + turnRightAnim: Int?, + ) { + setBaseAnimationSet( + turnLeftAnim = turnLeftAnim ?: Int.MIN_VALUE, + turnRightAnim = turnRightAnim ?: Int.MIN_VALUE, + ) + } + + /** + * Sets the walk animations of this NPC. If any of the animations is null, that animation + * will not be overwritten by the client, allowing a subset of the below animations + * to be overridden. + * @param walkAnim the animation used when the NPC walks forward + * @param walkAnimBack the animation used when the NPC walks backwards + * @param walkAnimLeft the animation used when the NPC walks to the left + * @param walkAnimRight the animation used when the NPC walks to the right + */ + public fun setWalkAnims( + walkAnim: Int?, + walkAnimBack: Int?, + walkAnimLeft: Int?, + walkAnimRight: Int?, + ) { + setBaseAnimationSet( + walkAnim = walkAnim ?: Int.MIN_VALUE, + walkAnimBack = walkAnimBack ?: Int.MIN_VALUE, + walkAnimLeft = walkAnimLeft ?: Int.MIN_VALUE, + walkAnimRight = walkAnimRight ?: Int.MIN_VALUE, + ) + } + + /** + * Sets the run animations of this NPC. If any of the animations is null, that animation + * will not be overwritten by the client, allowing a subset of the below animations + * to be overridden. + * @param runAnim the animation used when the NPC runs forward + * @param runAnimBack the animation used when the NPC runs backwards + * @param runAnimLeft the animation used when the NPC runs to the left + * @param runAnimRight the animation used when the NPC runs to the right + */ + public fun setRunAnims( + runAnim: Int?, + runAnimBack: Int?, + runAnimLeft: Int?, + runAnimRight: Int?, + ) { + setBaseAnimationSet( + runAnim = runAnim ?: Int.MIN_VALUE, + runAnimBack = runAnimBack ?: Int.MIN_VALUE, + runAnimLeft = runAnimLeft ?: Int.MIN_VALUE, + runAnimRight = runAnimRight ?: Int.MIN_VALUE, + ) + } + + /** + * Sets the crawl animations of this NPC. If any of the animations is null, that animation + * will not be overwritten by the client, allowing a subset of the below animations + * to be overridden. + * @param crawlAnim the animation used when the NPC crawls forward + * @param crawlAnimBack the animation used when the NPC crawls backwards + * @param crawlAnimLeft the animation used when the NPC crawls to the left + * @param crawlAnimRight the animation used when the NPC crawls to the right + */ + public fun setCrawlAnims( + crawlAnim: Int?, + crawlAnimBack: Int?, + crawlAnimLeft: Int?, + crawlAnimRight: Int?, + ) { + setBaseAnimationSet( + crawlAnim = crawlAnim ?: Int.MIN_VALUE, + crawlAnimBack = crawlAnimBack ?: Int.MIN_VALUE, + crawlAnimLeft = crawlAnimLeft ?: Int.MIN_VALUE, + crawlAnimRight = crawlAnimRight ?: Int.MIN_VALUE, + ) + } + + /** + * Changes the head icon of a NPC to the sprite at the provided group and sprite index. + * @param slot the slot of the headicon, a value of 0-8 (exclusive) + * @param group the sprite group id in the cache. + * @param index the index of the sprite in that sprite file, as sprite files contain + * multiple sprites together. + */ + @Deprecated( + message = "Deprecated. Use setHeadIconChange(slot, group, index) for consistency.", + replaceWith = ReplaceWith("setHeadIconChange(slot, group, index)"), + ) + public fun headIconChange( + slot: Int, + group: Int, + index: Int, + ) { + setHeadIconChange(slot, group, index) + } + + /** + * Changes the head icon of a NPC to the sprite at the provided group and sprite index. + * @param slot the slot of the headicon, a value of 0-8 (exclusive) + * @param group the sprite group id in the cache. + * @param index the index of the sprite in that sprite file, as sprite files contain + * multiple sprites together. + */ + public fun setHeadIconChange( + slot: Int, + group: Int, + index: Int, + ) { + checkCommunicationThread() + verify { + require(slot in 0..<8) { + "Unexpected headicon slot: $slot, expected slot range: 0..<8" + } + require(index == -1 || index in UNSIGNED_SHORT_RANGE) { + "Unexpected headicon index: $index, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + } + val headIcons = blocks.headIconCustomisation + headIcons.headIconGroups[slot] = group + headIcons.headIconIndices[slot] = index.toShort() + headIcons.flag = headIcons.flag or (1 shl slot) + flags = flags or HEADICON_CUSTOMISATION + } + + /** + * Resets the head icon at the specified [slot]. + * @param slot the slot of the head icon to reset. + */ + public fun resetHeadIcon(slot: Int) { + checkCommunicationThread() + verify { + require(slot in 0..<8) { + "Unexpected headicon slot: $slot, expected slot range: 0..<8" + } + } + val headIcons = blocks.headIconCustomisation + headIcons.headIconGroups[slot] = -1 + headIcons.headIconIndices[slot] = -1 + headIcons.flag = headIcons.flag or (1 shl slot) + flags = flags or HEADICON_CUSTOMISATION + } + + /** + * Resets all head icons which have been modified in the past. + * If the given avatar has had no headicon changes, this function will + * have no effect. + */ + public fun resetHeadIcons() { + checkCommunicationThread() + val headIcons = blocks.headIconCustomisation + if (headIcons.flag == 0) return + for (slot in 0..<8) { + headIcons.headIconGroups[slot] = -1 + headIcons.headIconIndices[slot] = -1 + headIcons.flag = headIcons.flag or (1 shl slot) + } + flags = flags or HEADICON_CUSTOMISATION + } + + /** + * Resets any chathead customisations applied to this NPC. + */ + public fun resetHeadCustomisations() { + checkCommunicationThread() + blocks.headCustomisation.customisation = null + flags = flags or HEAD_CUSTOMISATION + } + + /** + * Sets the chathead of the NPC to be a mirror of the local player's own chathead. + */ + public fun setHeadCustomisationMirrored() { + checkCommunicationThread() + blocks.headCustomisation.customisation = + TypeCustomisation( + emptyList(), + emptyList(), + emptyList(), + true, + ) + flags = flags or HEAD_CUSTOMISATION + } + + /** + * Sets the chat head customisation for this NPC. + * @param models the list of models to override; if the list is empty, models are not overridden. + * @param recolours the list of recolours to apply to this NPC; if the list is empty, + * recolours are not applied. If recolours are provided, the server MUST ensure that the number of recolours + * matches the number of source colours defined on the NPC in the cache, as the client reads based on the + * cache configuration. + * @param retextures the list of retextures to apply to this NPC; if the list is empty, + * retextures are not applied. If retextures are provided, the server MUST ensure that the number of retextures + * matches the number of source textures defined on the NPC in the cache, as the client reads based on the + * cache configuration. + */ + public fun setHeadCustomisation( + models: List, + recolours: List, + retextures: List, + ) { + checkCommunicationThread() + blocks.headCustomisation.customisation = + TypeCustomisation( + models, + recolours, + retextures, + false, + ) + flags = flags or HEAD_CUSTOMISATION + } + + /** + * Resets any NPC body customisations applied. + */ + public fun resetBodyCustomisations() { + checkCommunicationThread() + blocks.bodyCustomisation.customisation = null + flags = flags or BODY_CUSTOMISATION + } + + /** + * Sets the NPC to mirror the body of the local player in its entirety, including any worn gear. + */ + public fun setBodyCustomisationMirrored() { + checkCommunicationThread() + blocks.bodyCustomisation.customisation = + TypeCustomisation( + emptyList(), + emptyList(), + emptyList(), + true, + ) + flags = flags or BODY_CUSTOMISATION + } + + /** + * Sets the NPC body customisation for this NPC. + * @param models the list of models to override; if the list is empty, models are not overridden. + * @param recolours the list of recolours to apply to this NPC; if the list is empty, + * recolours are not applied. If recolours are provided, the server MUST ensure that the number of recolours + * matches the number of source colours defined on the NPC in the cache, as the client reads based on the + * cache configuration. + * @param retextures the list of retextures to apply to this NPC; if the list is empty, + * retextures are not applied. If retextures are provided, the server MUST ensure that the number of retextures + * matches the number of source textures defined on the NPC in the cache, as the client reads based on the + * cache configuration. + */ + public fun setBodyCustomisation( + models: List, + recolours: List, + retextures: List, + ) { + checkCommunicationThread() + blocks.bodyCustomisation.customisation = + TypeCustomisation( + models, + recolours, + retextures, + false, + ) + flags = flags or BODY_CUSTOMISATION + } + + /** + * Clears any transient information and resets the flag to zero at the end of the cycle. + */ + internal fun postUpdate() { + clearTransientExtendedInformation() + flags = 0 + } + + /** + * Resets all the properties of this extended info object, making it ready for use + * by another avatar. + */ + internal fun reset() { + flags = 0 + blocks.sequence.clear() + blocks.facePathingEntity.clear() + blocks.say.clear() + blocks.exactMove.clear() + blocks.spotAnims.clear() + blocks.hit.clear() + blocks.tinting.clear() + blocks.faceAngle.clear() + blocks.transformation.clear() + blocks.bodyCustomisation.clear() + blocks.headCustomisation.clear() + blocks.combatLevelChange.clear() + blocks.visibleOps.clear() + blocks.nameChange.clear() + blocks.headIconCustomisation.clear() + blocks.baseAnimationSet.clear() + } + + /** + * Checks if the avatar has any extended info flagged. + * @return whether any extended info flags are set. + */ + internal fun hasExtendedInfo(): Boolean { + return this.flags != 0 + } + + /** + * Pre-computes all the buffers for this avatar. + * Pre-computation is done, so we don't have to calculate these extended info blocks + * for every avatar that observes us. Instead, we can do more performance-efficient + * operations of native memory copying to get the latest extended info blocks. + */ + internal fun precompute() { + // Hits and tinting do not get precomputed + + precomputeCached() + if (flags and SEQUENCE != 0) { + blocks.sequence.precompute(allocator, huffmanCodec) + } + if (flags and SAY != 0) { + blocks.say.precompute(allocator, huffmanCodec) + } + if (flags and EXACT_MOVE != 0) { + blocks.exactMove.precompute(allocator, huffmanCodec) + } + if (flags and SPOTANIM != 0) { + blocks.spotAnims.precompute(allocator, huffmanCodec) + } + if (flags and TINTING != 0) { + blocks.tinting.precompute(allocator, huffmanCodec) + } + if (flags and FACE_ANGLE != 0) { + blocks.faceAngle.precompute(allocator, huffmanCodec) + } + if (flags and TRANSFORMATION != 0) { + blocks.transformation.precompute(allocator, huffmanCodec) + } + } + + /** + * Precomputes the extended info blocks which are cached and potentially transmitted + * to any players who newly observe this npc. The full list of extended info blocks + * which must be placed in here is seen in [getLowToHighResChangeExtendedInfoFlags]. + * Every condition there must be among this function, else it is possible to run into + * scenarios where a block isn't computed but is required in the future. + */ + internal fun precomputeCached() { + if (flags and OPS != 0) { + blocks.visibleOps.precompute(allocator, huffmanCodec) + } + if (flags and HEADICON_CUSTOMISATION != 0) { + blocks.headIconCustomisation.precompute(allocator, huffmanCodec) + } + if (flags and NAME_CHANGE != 0) { + blocks.nameChange.precompute(allocator, huffmanCodec) + } + if (flags and HEAD_CUSTOMISATION != 0) { + blocks.headCustomisation.precompute(allocator, huffmanCodec) + } + if (flags and BODY_CUSTOMISATION != 0) { + blocks.bodyCustomisation.precompute(allocator, huffmanCodec) + } + if (flags and LEVEL_CHANGE != 0) { + blocks.combatLevelChange.precompute(allocator, huffmanCodec) + } + if (flags and FACE_PATHINGENTITY != 0) { + blocks.facePathingEntity.precompute(allocator, huffmanCodec) + } + if (flags and BAS_CHANGE != 0) { + blocks.baseAnimationSet.precompute(allocator, huffmanCodec) + } + if (flags and TRANSFORMATION != 0) { + blocks.transformation.precompute(allocator, huffmanCodec) + } + } + + /** + * Writes the extended info block of this avatar for the given observer. + * @param oldSchoolClientType the client that the observer is using. + * @param buffer the buffer into which the extended info block should be written. + * @param observerIndex index of the player avatar that is observing us. + * @param remainingAvatars the number of avatars that must still be updated for + * the given [observerIndex], necessary to avoid memory overflow. + */ + internal fun pExtendedInfo( + oldSchoolClientType: OldSchoolClientType, + buffer: JagByteBuf, + observerIndex: Int, + extendedIndex: Int, + remainingAvatars: Int, + extraFlag: Int, + ) { + val flag = this.flags or extraFlag + // We _cannot_ skip the very first avatar that is meant to have extended info. + // If our NPC info only has a single byte for extended info written as a whole, + // the protocol will fail the `16 + 12` check (due to terminator being 16 bits, + // plus the single byte extended info - falling below the required 28 bits threshold) + // By ensuring the flag isn't written as zero (it can never be zero if this function + // is executed), we ensure that at least two bytes are being written for extended + // info as a whole - since there are no extended info blocks which write no information. + if (extendedIndex > 0 && + !filter.accept( + buffer.writableBytes(), + flag, + remainingAvatars, + false, + ) + ) { + buffer.p1(0) + return + } + val writer = + requireNotNull(writers[oldSchoolClientType.id]) { + "Extended info writer missing for client $oldSchoolClientType" + } + + writer.pExtendedInfo( + buffer, + avatarIndex, + observerIndex, + flag, + blocks, + flagWriteIndex = -1, + ) + } + + /** + * Gets the set of extended info blocks that were previously set but also + * need to be transmitted to any new users. + * @return the bit flag of all the non-transient extended info blocks that were previously flagged. + */ + internal fun getLowToHighResChangeExtendedInfoFlags(): Int { + var flag = 0 + if (this.flags and OPS == 0 && + blocks.visibleOps.ops != VisibleOps.DEFAULT_OPS + ) { + flag = flag or OPS + } + if (this.flags and HEADICON_CUSTOMISATION == 0 && + blocks.headIconCustomisation.flag != HeadIconCustomisation.DEFAULT_FLAG + ) { + flag = flag or HEADICON_CUSTOMISATION + } + if (this.flags and NAME_CHANGE == 0 && + blocks.nameChange.name != null + ) { + flag = flag or NAME_CHANGE + } + if (this.flags and HEAD_CUSTOMISATION == 0 && + blocks.headCustomisation.customisation != null + ) { + flag = flag or HEAD_CUSTOMISATION + } + if (this.flags and BODY_CUSTOMISATION == 0 && + blocks.bodyCustomisation.customisation != null + ) { + flag = flag or BODY_CUSTOMISATION + } + if (this.flags and LEVEL_CHANGE == 0 && + blocks.combatLevelChange.level != + CombatLevelChange.DEFAULT_LEVEL_OVERRIDE + ) { + flag = flag or LEVEL_CHANGE + } + if (this.flags and FACE_PATHINGENTITY == 0 && + blocks.facePathingEntity.index != FacePathingEntity.DEFAULT_VALUE + ) { + flag = flag or FACE_PATHINGENTITY + } + if (this.flags and BAS_CHANGE == 0 && + blocks.baseAnimationSet.overrides != BaseAnimationSet.DEFAULT_OVERRIDES_FLAG + ) { + flag = flag or BAS_CHANGE + } + if (this.flags and TRANSFORMATION == 0 && + blocks.transformation.id.toInt() in EXTENDED_NPC_ID_RANGE + ) { + flag = flag or TRANSFORMATION + } + return flag + } + + /** + * Clears any transient extended info that was flagged in this cycle. + */ + private fun clearTransientExtendedInformation() { + val flags = this.flags + if (flags == 0) return + if (flags and SEQUENCE != 0) { + blocks.sequence.clear() + } + if (flags and SAY != 0) { + blocks.say.clear() + } + if (flags and EXACT_MOVE != 0) { + blocks.exactMove.clear() + } + if (flags and SPOTANIM != 0) { + blocks.spotAnims.clear() + } + if (flags and HITS != 0) { + blocks.hit.clear() + } + if (flags and TINTING != 0) { + blocks.tinting.clear() + } + if (flags and FACE_ANGLE != 0) { + blocks.faceAngle.clear() + } + // While this is a persistent flag, we still need to clear any "resets", + // so we aren't consistently sending "clear this head icon change" to any + // future players even though there hasn't been a headicon change in a while. + if (flags and HEADICON_CUSTOMISATION != 0) { + val headIcons = blocks.headIconCustomisation + val iconFlag = headIcons.flag + for (i in 0..<8) { + if (iconFlag and (1 shl i) == 0) continue + val group = headIcons.headIconGroups[i] + if (group != -1) continue + val index = headIcons.headIconIndices[i].toInt() + if (index != -1) continue + // Unflag any headicons which were reset, to avoid transmitting to new future + // observers - the NPC will by default not have any headicons anyway + headIcons.flag = headIcons.flag and (1 shl i).inv() + } + } + } + + override fun toString(): String = + "NpcAvatarExtendedInfo(" + + "avatarIndex=$avatarIndex, " + + "flags=$flags" + + ")" + + public companion object { + private val SIGNED_BYTE_RANGE: IntRange = Byte.MIN_VALUE.toInt()..Byte.MAX_VALUE.toInt() + private val UNSIGNED_BYTE_RANGE: IntRange = UByte.MIN_VALUE.toInt()..UByte.MAX_VALUE.toInt() + private val UNSIGNED_SHORT_RANGE: IntRange = UShort.MIN_VALUE.toInt()..UShort.MAX_VALUE.toInt() + private val UNSIGNED_SMART_1_OR_2_RANGE: IntRange = 0..0x7FFF + private val EXTENDED_NPC_ID_RANGE: IntRange = 16384..65534 + private val HIT_TYPE_RANGE: IntRange = -1..0x7FFD + + // Observer-dependent flags, utilizing the lowest bits as we store observer flags in a byte array + // IMPORTANT: As we store it in a byte array, we currently only support 8 blocks + // all of which are currently filled. If more are needed, the data structure needs + // to be updated to a short array. + public const val OPS: Int = 0x1 + public const val HEADICON_CUSTOMISATION: Int = 0x2 + public const val NAME_CHANGE: Int = 0x4 + public const val HEAD_CUSTOMISATION: Int = 0x8 + public const val BODY_CUSTOMISATION: Int = 0x10 + public const val LEVEL_CHANGE: Int = 0x20 + public const val FACE_PATHINGENTITY: Int = 0x40 + public const val BAS_CHANGE: Int = 0x80 + + // "Static" flags, the bit values here are irrelevant + public const val TINTING: Int = 0x100 + public const val SAY: Int = 0x200 + public const val HITS: Int = 0x400 + public const val TRANSFORMATION: Int = 0x1000 + public const val SEQUENCE: Int = 0x2000 + public const val EXACT_MOVE: Int = 0x4000 + public const val SPOTANIM: Int = 0x8000 + public const val FACE_ANGLE: Int = 0x10000 + + /** + * Executes the [block] if input verification is enabled, + * otherwise does nothing. Verification should be enabled for + * development environments, to catch problems mid-development. + * In production, or during benchmarking, verification should be disabled, + * as there is still some overhead to running verifications. + */ + private inline fun verify(crossinline block: () -> Unit) { + if (RSProtFlags.extendedInfoInputVerification) { + block() + } + } + + /** + * Builds an extended info writer array indexed by provided client types. + * All client types which are utilized must be registered to avoid runtime errors. + */ + private fun buildClientWriterArray( + extendedInfoWriters: List, + ): Array { + val array = + arrayOfNulls( + OldSchoolClientType.COUNT, + ) + for (writer in extendedInfoWriters) { + array[writer.oldSchoolClientType.id] = writer + } + return array + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfoBlocks.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfoBlocks.kt new file mode 100644 index 000000000..248e692a6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarExtendedInfoBlocks.kt @@ -0,0 +1,103 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.ExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcExtendedInfoEncoders +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BaseAnimationSet +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.BodyCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.CombatLevelChange +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.HeadIconCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NameChange +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.NpcTinting +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.extendedinfo.Transformation +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.ExactMove +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps + +private typealias NEnc = NpcExtendedInfoEncoders +private typealias HeadIcon = HeadIconCustomisation +private typealias NpcExtendedInfoWriters = + List> + +/** + * A data structure to bring all the extended info blocks together, + * so the information can be passed onto various client-specific encoders. + * @param writers the list of client-specific writers. + * The writers must be client-specific too, not just encoders, as + * the order in which the extended info blocks get written must follow + * the exact order described by the client. + */ +public class NpcAvatarExtendedInfoBlocks( + writers: NpcExtendedInfoWriters, +) { + public val spotAnims: SpotAnimList = SpotAnimList(encoders(writers, NEnc::spotAnim)) + public val say: Say = Say(encoders(writers, NEnc::say)) + public val visibleOps: VisibleOps = VisibleOps(encoders(writers, NEnc::visibleOps)) + public val exactMove: ExactMove = + ExactMove( + encoders( + writers, + NEnc::exactMove, + ), + ) + public val sequence: Sequence = + Sequence(encoders(writers, NEnc::sequence)) + public val tinting: NpcTinting = + NpcTinting( + encoders( + writers, + NEnc::tinting, + ), + ) + public val headIconCustomisation: HeadIcon = HeadIcon(encoders(writers, NEnc::headIconCustomisation)) + public val nameChange: NameChange = NameChange(encoders(writers, NEnc::nameChange)) + public val headCustomisation: HeadCustomisation = + HeadCustomisation( + encoders( + writers, + NEnc::headCustomisation, + ), + ) + public val bodyCustomisation: BodyCustomisation = BodyCustomisation(encoders(writers, NEnc::bodyCustomisation)) + public val transformation: Transformation = Transformation(encoders(writers, NEnc::transformation)) + public val combatLevelChange: CombatLevelChange = + CombatLevelChange( + encoders( + writers, + NEnc::combatLevelChange, + ), + ) + public val hit: Hit = Hit(encoders(writers, NEnc::hit)) + public val faceAngle: FaceAngle = FaceAngle(encoders(writers, NEnc::faceAngle)) + public val facePathingEntity: FacePathingEntity = FacePathingEntity(encoders(writers, NEnc::facePathingEntity)) + public val baseAnimationSet: BaseAnimationSet = BaseAnimationSet(encoders(writers, NEnc::baseAnimationSet)) + + private companion object { + /** + * Builds a client-specific map of encoders for a specific extended info block, + * keyed by [OldSchoolClientType.id]. + * If a client hasn't been registered, the encoder at that index will be null. + * @param allEncoders all the client-specific extended info writers for the given type. + * @param selector a higher order function to retrieve a specific extended info block from + * the full structure of all the extended info blocks. + * @return a map of client-specific encoders of the given extended info block, + * keyed by [OldSchoolClientType.id]. + */ + private inline fun , reified E : ExtendedInfoEncoder> encoders( + allEncoders: NpcExtendedInfoWriters, + selector: (NpcExtendedInfoEncoders) -> E, + ): ClientTypeMap = + ClientTypeMap.ofType(allEncoders, OldSchoolClientType.COUNT) { + it.encoders.oldSchoolClientType to selector(it.encoders) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFactory.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFactory.kt new file mode 100644 index 000000000..be9728d0a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFactory.kt @@ -0,0 +1,143 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.game.outgoing.info.AvatarPriority +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +/** + * NPC avatar factor is responsible for allocating new avatars for NPCs, + * or, if possible, re-using old ones that are no longer in use, to avoid generating + * mass amounts of garbage. + * @param allocator the byte buffer allocator used to pre-compute bitcodes for this avatar. + * @param extendedInfoFilter the filter used to determine whether the given NPC can still + * have extended info blocks written to it, or if we have to utilize a fall-back and tell + * the client that despite extended info having been flagged, we cannot write it (by writing + * the flag itself as a zero, so the client reads no further information). + * @param extendedInfoWriter the client-specific extended info writers for NPC information. + * @param huffmanCodec the huffman codec is used to compress chat extended info. + * While NPCs do not currently have any such extended info blocks, the interface requires + * it be passed in, so we must still provide it. + * @param zoneIndexStorage the collection that keeps track of npc indices in various zones. + * @param npcInfoProtocolSupplier a supplier for the npc info protocol. This is a cheap hack + * to get around a circular dependency issue without rewriting a great deal of code. + */ +public class NpcAvatarFactory( + allocator: ByteBufAllocator, + extendedInfoFilter: ExtendedInfoFilter, + extendedInfoWriter: List, + huffmanCodec: HuffmanCodecProvider, + zoneIndexStorage: ZoneIndexStorage, + npcInfoProtocolSupplier: DeferredNpcInfoProtocolSupplier, +) { + /** + * The avatar repository is responsible for keeping track of all avatars, including ones + * which are no longer in use - but can be used in the future. + */ + internal val avatarRepository: NpcAvatarRepository = + NpcAvatarRepository( + allocator, + extendedInfoFilter, + extendedInfoWriter, + huffmanCodec, + zoneIndexStorage, + npcInfoProtocolSupplier, + ) + + /** + * Allocates a new NPC avatar, or re-uses an older cached one if possible. + * + * Npc direction table: + * ``` + * | Id | Client Angle | Direction | + * |:--:|:------------:|:----------:| + * | 0 | 768 | North-West | + * | 1 | 1024 | North | + * | 2 | 1280 | North-East | + * | 3 | 512 | West | + * | 4 | 1536 | East | + * | 5 | 256 | South-West | + * | 6 | 0 | South | + * | 7 | 1792 | South-East | + * ``` + * + * @param index the index of the npc in the world + * @param id the id of the npc in the world, limited to range of 0..16383 + * @param level the height level of the npc + * @param x the absolute x coordinate of the npc + * @param z the absolute z coordinate of the npc + * @param spawnCycle the game cycle on which the npc spawned into the world; + * for static NPCs, this would always be zero. This is only used by the C++ clients. + * @param direction the direction that the npc will face on spawn (see table above) + * @param priority the priority group a NPC belongs into. See [NpcInfo.setPriorityCaps] for greater + * documentation. + * @param specific if true, the NPC will only render to players that have explicitly marked this + * NPC's index as specific-visible, anyone else will be unable to see it. If it's false, anyone can + * see the NPC regardless. + * @param renderDistance the distance from which the NPC will render by default. + * Note that for larger distances, the search radius in zones must also be increased + * to allow it to even find the NPC. The actual distance to compare ends up being + * max(npc.renderDistance, npcinfo.renderDistance) - picking the highest of the two, + * while still constraining it to the zone search range. + * @return a npc avatar with the above provided details. + */ + @JvmOverloads + public fun alloc( + index: Int, + id: Int, + level: Int, + x: Int, + z: Int, + spawnCycle: Int = 0, + direction: Int = 0, + priority: AvatarPriority = AvatarPriority.NORMAL, + specific: Boolean = false, + renderDistance: Int = 15, + ): NpcAvatar { + checkCommunicationThread() + require(index in 0..65534) { + "Npc avatar index out of bounds: $index" + } + require(id in 0..16383) { + "Npc id cannot be outside of 0..16383 range" + } + require(level in 0..3) { + "Level cannot be outside of 0..3 range" + } + require(x in 0..16383) { + "X coordinate cannot be outside of 0..16383 range" + } + require(z in 0..16383) { + "Z coordinate cannot be outside of 0..16383 range" + } + require(direction in 0..7) { + "Direction must be in range of 0..7" + } + require(renderDistance >= 0) { + "Render distance cannot be negative." + } + return avatarRepository.getOrAlloc( + index, + id, + level, + x, + z, + spawnCycle, + direction, + priority, + specific, + renderDistance, + ) + } + + /** + * Releases the avatar back into the repository to be used by other NPCs. + * @param avatar the avatar to release. + */ + public fun release(avatar: NpcAvatar) { + checkCommunicationThread() + avatarRepository.release(avatar) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFilter.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFilter.kt new file mode 100644 index 000000000..139e23e46 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarFilter.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +/** + * An optional filter that must be passed before moving a NPC from + * low resolution to high resolution for a given player, and to keep + * a NPC in high resolution after the fact. + * An example of this is hiding any NPCs which would be morphed to + * id -1 as a result of a specific varbit or varp value. + * + * This filter should ideally be efficient, as it is hit a lot. + * Furthermore, it must be thread-safe, as it will potentially be called + * from multiple different threads, depending on the threading used by the server. + */ +public fun interface NpcAvatarFilter { + /** + * Whether to accept a specific NPC into high resolution view. + * Note that this filter is invoked last, when every other check + * has already passed. + * + * @param playerIndex the index of the player whose npc info is doing the check. + * @param npcIndex the index of the npc that will be added to high resolution, + * if the filter passes. + * @return whether to transmit the NPC to the client in high resolution. + */ + public fun accept( + playerIndex: Int, + npcIndex: Int, + ): Boolean +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarRepository.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarRepository.kt new file mode 100644 index 000000000..0ee594071 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcAvatarRepository.kt @@ -0,0 +1,201 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.game.outgoing.info.AvatarPriority +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.NpcAvatarDetails +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import java.lang.ref.ReferenceQueue +import java.lang.ref.SoftReference + +/** + * The NPC avatar repository is a class responsible for keeping track of all the avatars + * in the game, as well as allocating/re-using new instances if needed. + * @property allocator the byte buffer allocator used to pre-compute bitcodes for an avatar. + * @property extendedInfoFilter the filter used to determine whether the given NPC can still + * have extended info blocks written to it, or if we have to utilize a fall-back and tell + * the client that despite extended info having been flagged, we cannot write it (by writing + * the flag itself as a zero, so the client reads no further information). + * @property extendedInfoWriter the client-specific extended info writers for NPC information. + * @property huffmanCodec the huffman codec is used to compress chat extended info. + * While NPCs do not currently have any such extended info blocks, the interface requires + * it be passed in, so we must still provide it. + * @property zoneIndexStorage the zone index storage responsible for tracking all the NPCs + * based on the zones in which they lie. + * @property npcInfoProtocolSupplier a supplier for the npc info protocol. This is a cheap hack + * to get around a circular dependency issue without rewriting a great deal of code. + */ +internal class NpcAvatarRepository( + private val allocator: ByteBufAllocator, + private val extendedInfoFilter: ExtendedInfoFilter, + private val extendedInfoWriter: List, + private val huffmanCodec: HuffmanCodecProvider, + private val zoneIndexStorage: ZoneIndexStorage, + private val npcInfoProtocolSupplier: DeferredNpcInfoProtocolSupplier, +) { + /** + * The array of npc avatars that currently exist in the game. + */ + private val elements: Array = arrayOfNulls(AVATAR_CAPACITY) + + /** + * A soft-reference queue of avatars that are no longer in use. + * If the server requires the memory, these references will be freed up, but this is + * only as a last resort. Other than that, these instances should remain available + * for a long period of time - rightfully so as extended info blocks primarily + * are the heavy part. + */ + private val queue: ReferenceQueue = ReferenceQueue() + + /** + * Gets a npc avatar at the provided index, or null if it doesn't exist yet. + * @param idx the index of the avatar to obtain + * @return the npc avatar, or null if it doesn't exist + * @throws ArrayIndexOutOfBoundsException if the [idx] is below 0, or >= [AVATAR_CAPACITY] + */ + fun getOrNull(idx: Int): NpcAvatar? = elements[idx] + + /** + * Gets an older avatar, or makes a new one depending on the circumstances. + * If using an older one, this function is responsible for sanitizing the older avatar + * so that it is equal to a new instance. + * + * Npc direction table: + * ``` + * | Id | Client Angle | Direction | + * |:--:|:------------:|:----------:| + * | 0 | 768 | North-West | + * | 1 | 1024 | North | + * | 2 | 1280 | North-East | + * | 3 | 512 | West | + * | 4 | 1536 | East | + * | 5 | 256 | South-West | + * | 6 | 0 | South | + * | 7 | 1792 | South-East | + * ``` + * + * @param index the index of the npc in the world + * @param id the id of the npc in the world, limited to range of 0..16383 + * @param level the height level of the npc + * @param x the absolute x coordinate of the npc + * @param z the absolute z coordinate of the npc + * @param spawnCycle the game cycle on which the npc spawned into the world; + * for static NPCs, this would always be zero. This is only used by the C++ clients. + * @param direction the direction that the npc will face on spawn (see table above) + * @param priority the priority group a NPC belongs into. See [NpcInfo.setPriorityCaps] for greater + * documentation. + * @param specific if true, the NPC will only render to players that have explicitly marked this + * NPC's index as specific-visible, anyone else will be unable to see it. If it's false, anyone can + * see the NPC regardless. + * @param renderDistance the distance from which the NPC will render by default. + * Note that for larger distances, the search radius in zones must also be increased + * to allow it to even find the NPC. The actual distance to compare ends up being + * max(npc.renderDistance, npcinfo.renderDistance) - picking the highest of the two, + * while still constraining it to the zone search range. + * @return a npc avatar with the above provided details. + */ + fun getOrAlloc( + index: Int, + id: Int, + level: Int, + x: Int, + z: Int, + spawnCycle: Int = 0, + direction: Int = 0, + priority: AvatarPriority = AvatarPriority.NORMAL, + specific: Boolean = false, + renderDistance: Int = 15, + ): NpcAvatar { + val old = this.elements[index] + require(old == null) { + "NPC Avatar with index $index is already allocated: $old" + } + val existing = queue.poll()?.get() + if (existing != null) { + existing.resetObservers() + val details = existing.details + resetTransientDetails(details) + details.index = index + details.id = id + details.currentCoord = CoordGrid(level, x, z) + details.spawnCycle = spawnCycle + details.direction = direction + details.allocateCycle = NpcInfoProtocol.cycleCount + details.priorityBitcode = priority.bitcode + details.specific = specific + details.renderDistance = renderDistance + zoneIndexStorage.add(index, details.currentCoord) + elements[index] = existing + return existing + } + val extendedInfo = + NpcAvatarExtendedInfo( + index, + extendedInfoFilter, + extendedInfoWriter, + allocator, + huffmanCodec, + ) + val avatar = + NpcAvatar( + index, + id, + level, + x, + z, + spawnCycle, + direction, + priority, + specific, + NpcInfoProtocol.cycleCount, + renderDistance, + extendedInfo, + zoneIndexStorage, + ) + zoneIndexStorage.add(index, avatar.details.currentCoord) + elements[index] = avatar + return avatar + } + + /** + * Releases avatar back into the pool for it to be used later in the future, if possible. + * @param avatar the avatar to release. + */ + fun release(avatar: NpcAvatar) { + val index = avatar.details.index + // Ensure the avatars share the same reference! + require(this.elements[index] === avatar) { + "Attempting to release an invalid NPC avatar: $avatar, ${this.elements[index]}" + } + if (avatar.details.specific) { + val protocol = npcInfoProtocolSupplier.get() + for (i in 0.. 0 + + /** + * Gets the current number of players observing this avatar. + */ + public fun getObserverCount(): Int = counter.get() + + /** + * Checks whether the player at the specified [index] is currently observing + * this NPC avatar. + * @param index the index of the player to check. + */ + private fun isObservingPlayer(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + return this.observingPlayers[longIndex] and bit != 0L + } + + /** + * Marks the player at index [index] as observing this NPC. + * @param index the index of the player to mark as observing this NPC. + * @return true if the player at index [index] was not already observing this NPC, false if it was. + */ + private fun setObservingPlayer(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val players = this.observingPlayers + while (true) { + val cur = players[longIndex] + if ((cur and bit) != 0L) return false + val assigned = + players.weakCompareAndSetVolatile( + longIndex, + cur, + cur or bit, + ) + if (!assigned) continue + return (cur ushr (index and 0x3F) and 0x1) == 0L + } + } + + /** + * Unmarks the player at index [index] as observing this NPC. + * @param index the index of the player to unmark as observing this NPC. + * @return true if the player was previously observing this NPC, false if not. + */ + private fun unsetObservingPlayer(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val players = this.observingPlayers + while (true) { + val cur = players[longIndex] + if ((cur and bit) == 0L) return false + val assigned = + players.weakCompareAndSetVolatile( + longIndex, + cur, + cur and bit.inv(), + ) + if (!assigned) continue + return (cur ushr (index and 0x3F) and 0x1) != 0L + } + } + + override fun toString(): String = + "NpcAvatarTracker(" + + "counter=$counter, " + + "observingPlayers=$observingPlayers" + + ")" + + /** + * A Set implementation to provide easy access over all the player avatars observing + * this NPC. + * Note that only a single instance per NPC is ever created, meaning this should not be + * stored for long-term use. The iterator of this set will throw a concurrent modification + * exception if it is accessed across multiple game cycles. + * + * Furthermore, this set does not preserve iteration order, but it does ensure an ascending + * order of indices, allowing for potential use of features like + * [Binary Search](https://en.wikipedia.org/wiki/Binary_search) + */ + public inner class AvatarSet : Set { + override val size: Int + get() = counter.get() + + override fun contains(element: Int): Boolean { + if (element < 0 || element >= PROTOCOL_CAPACITY) { + return false + } + return isObservingPlayer(element) + } + + override fun containsAll(elements: Collection): Boolean { + if (elements.isEmpty()) { + return true + } + for (element in elements) { + if (!contains(element)) { + return false + } + } + return true + } + + override fun isEmpty(): Boolean = size == 0 + + override fun iterator(): Iterator = AvatarSetIterator(NpcInfoProtocol.cycleCount) + + override fun toString(): String = + buildString { + append("[") + for (element in this@AvatarSet) { + append(element).append(", ") + } + if (isNotEmpty()) { + delete(length - 2, length) + } + append("]") + } + + /** + * An iterator implementation of this avatar set. + * @property cycle the cycle at which the iterator was created. + * If the cycle does not align up with [NpcInfoProtocol.cycleCount], + * a [ConcurrentModificationException] will be thrown when trying to call any of the functions. + * @property next the index of the next element in the set. We store a property here to + * avoid doing double checks every time we wish to advance the iterator. + * @property searchStartIndex the index at which to begin searching for the next element. + */ + private inner class AvatarSetIterator( + private val cycle: Int, + ) : Iterator { + private var next: Int = NO_NEXT_CHECKED + private var searchStartIndex: Int = 0 + + override fun hasNext(): Boolean { + checkConcurrentModification() + if (next == NO_NEXT_CHECKED) { + setNextNodeIndex() + } + return next != NO_NEXT_SET + } + + /** + * Finds the next observing player index and sets it to the [next] property. + * If no result is found, [NO_NEXT_SET] will be assigned instead. + */ + private fun setNextNodeIndex() { + var longIndex = searchStartIndex ushr 6 + if (longIndex >= LONGS_IN_USE) { + this.next = NO_NEXT_SET + return + } + var slice = observingPlayers[longIndex] and (LONG_MASK shl searchStartIndex) + while (true) { + if (slice != 0L) { + this.next = (longIndex * Long.SIZE_BITS) + java.lang.Long.numberOfTrailingZeros(slice) + this.searchStartIndex = this.next + 1 + return + } + if (++longIndex == LONGS_IN_USE) { + this.next = NO_NEXT_SET + return + } + slice = observingPlayers[longIndex] + } + } + + override fun next(): Int { + checkConcurrentModification() + if (next == NO_NEXT_CHECKED) { + setNextNodeIndex() + } + val next = this.next + if (next == NO_NEXT_SET) { + throw NoSuchElementException() + } + this.next = NO_NEXT_CHECKED + return next + } + + /** + * Checks for concurrent modifications via ensuring the cycle count still matches up. + * The intent here is that the iterator should not be accessed across multiple cycles, + * as the contents of this bit set are likely to change and be invalid. + */ + private fun checkConcurrentModification() { + if (cycle != NpcInfoProtocol.cycleCount) { + throw ConcurrentModificationException( + "Npc avatar iterator cannot be accessed across cycles.", + ) + } + } + } + } + + private companion object { + /** + * A constant value indicating that no next index has been searched yet in the iterator, + * implying the next one should be seeker before determining the iterator has finished. + */ + private const val NO_NEXT_CHECKED: Int = -2 + + /** + * A constant value indicating that there are no more elements left to iterate over. + */ + private const val NO_NEXT_SET: Int = -1 + + /** + * The constant flag that has all bits in a long enabled. + */ + private const val LONG_MASK: Long = -1L + + /** + * The number of longs in use to match our 2048 player indices threshold. + */ + private const val LONGS_IN_USE: Int = PROTOCOL_CAPACITY ushr 6 + + /** + * Whether to track player indices from the NPC's perspective. + * If this is set to false, we will not be flagging player indices that + * are tracking each NPC. That tracking appears to cause ~13% performance hit + * in our multithreaded benchmark. If servers don't make use of this, they can + * opt out of it and gain that performance back. + */ + private val trackIndices = RSProtFlags.npcPlayerAvatarTracking + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfo.kt new file mode 100644 index 000000000..116fa3889 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfo.kt @@ -0,0 +1,1189 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.buffer.bitbuffer.toBitBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.ByteBufRecycler +import net.rsprot.protocol.game.outgoing.info.exceptions.InfoProcessException +import net.rsprot.protocol.game.outgoing.info.util.PacketResult +import net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfo +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.npcinfo.encoder.NpcResolutionChangeEncoder +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import net.rsprot.protocol.message.ConsumableMessage +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.math.max + +/** + * An implementation of the npc info packet. + * This class is responsible for bringing together all the bits of the npc info protocol, + * including copying all the pre-built buffers that were made beforehand. + * @property allocator the byte buffer allocator used to allocate new buffers to be used + * for the npc info packet, as well as the pre-built extended info buffers. + * @property repository the npc avatar repository, keeping track of every npc avatar that exists + * in the game. + * @property oldSchoolClientType the client the player owning this npc info packet is on + * @property localPlayerIndex the index of the local player that owns this npc info packet. + * @property zoneIndexStorage the zone index storage is used to look up the indices of NPCs near + * the player in an efficient manner. + * @property lowResolutionToHighResolutionEncoders a client map of low resolution to high resolution + * change encoders, used to move a npc into high resolution for the given player. + * As this is scrambled, a separate client-specific implementation is required. + * @property filter a npc avatar filter that must be passed to add/keep a npc in high resolution. + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Suppress("ReplaceUntilWithRangeUntil") +public class NpcInfo internal constructor( + private val allocator: ByteBufAllocator, + private val repository: NpcAvatarRepository, + private var oldSchoolClientType: OldSchoolClientType, + internal var localPlayerIndex: Int, + private val zoneIndexStorage: ZoneIndexStorage, + private val lowResolutionToHighResolutionEncoders: ClientTypeMap, + private val detailsStorage: NpcInfoWorldDetailsStorage, + private val recycler: ByteBufRecycler, + private val filter: NpcAvatarFilter?, + private var worldEntityInfo: WorldEntityInfo?, +) : ReferencePooledObject { + /** + * The maximum render distance how far a player will see other NPCs. + * Unlike with player info, this does not automatically resize to accommodate for nearby NPCs, + * as it is almost impossible for such a scenario to happen in the first place. + * It is confirmed that OldSchool RuneScape does not do it either. + */ + private var renderDistance: Int = DEFAULT_DISTANCE + + /** + * The radius in zones (8x8 tile blocks) to iterate over, in order to find NPCs to add + * to high resolution. After this iteration, the npc must still pass the distance checks, + * as well as build area checks. + */ + private var zoneSearchRadius: Int = DEFAULT_ZONE_SEARCH_RADIUS + + /** + * The exception that was caught during the processing of this player's npc info packet. + * This exception will be propagated further during the [toPacketResult] function call, + * allowing the server to handle it properly at a per-player basis. + */ + @Volatile + internal var exception: Exception? = null + + /** + * An array of world details, containing all the player info properties specific to a single world. + * The root world is placed at the end of this array, however id -1 will be treated as the root. + */ + internal val details: Array = arrayOfNulls(WORLD_ENTITY_CAPACITY + 1) + + /** + * The last cycle's coordinate of the local player, used to perform faster npc removal. + * If the player moves a greater distance than the [NpcInfo.renderDistance], we can make the assumption + * that all the existing high-resolution NPCs need to be removed, and thus remove them + * in a simplified manner, rather than applying a coordinate check on each one. This commonly + * occurs whenever a player teleports far away. + */ + internal var localPlayerLastCoord: CoordGrid = CoordGrid.INVALID + + /** + * The current coordinate of the local player used for the calculations of this npc info + * packet. This will be cross-referenced against NPCs to ensure they are within distance. + */ + internal var localPlayerCurrentCoord: CoordGrid = CoordGrid.INVALID + + /** + * An array of NPCs which are marked as specific-visible. Any NPC avatar that was explicitly marked + * as visible-to-specific-only will only render to players that mark that avatar's index as specific + * visible. Anyone else will be unable to see such NPCs. + */ + internal val specificVisible: LongArray = LongArray((NPC_INFO_CAPACITY + 1) ushr 6) + + override fun isDestroyed(): Boolean = this.exception != null + + /** + * Allocates a new NPC info tracking object for the respective [worldId], + * keeping track of everyone that's within this new world entity. + * @param worldId the new world entity id + */ + public fun allocateWorld(worldId: Int) { + checkCommunicationThread() + if (isDestroyed()) return + require(worldId in 0.. { + checkCommunicationThread() + if (isDestroyed()) return ArrayList(0) + val details = getDetails(worldId) + val collection = ArrayList(details.highResolutionNpcIndexCount) + for (i in 0..? { + checkCommunicationThread() + if (isDestroyed()) return null + val details = getDetailsOrNull(worldId) ?: return null + val collection = ArrayList(details.highResolutionNpcIndexCount) + for (i in 0.. appendHighResolutionIndices( + worldId: Int, + collection: T, + throwExceptionIfNoWorld: Boolean = true, + ): T where T : MutableCollection { + checkCommunicationThread() + if (isDestroyed()) return collection + val details = + if (throwExceptionIfNoWorld) { + getDetails(worldId) + } else { + getDetailsOrNull(worldId) ?: return collection + } + for (i in 0..= 0) { + "Low priority cap cannot be negative." + } + require(normalPrioritySoftCap >= 0) { + "Normal priority soft cap cannot be negative." + } + require(lowPriorityCap + normalPrioritySoftCap <= MAX_HIGH_RESOLUTION_NPCS) { + "The sum of low priority cap and normal priority soft cap must be $MAX_HIGH_RESOLUTION_NPCS or fewer." + } + val world = getDetails(worldId) + world.lowPriorityCap = lowPriorityCap + world.normalPrioritySoftCap = normalPrioritySoftCap + } + + /** + * Marks the specified NPC's [avatar] as specific-visible, meaning the NPC will render + * to this player if other conditions are met. Anyone that hasn't marked it as specific + * will be unable to see that NPC. + * @param avatar the NPC avatar whom to mark as specific-visible. + * @throws IllegalArgumentException if the [avatar] was not allocated as specific-only. + */ + public fun setSpecific(avatar: NpcAvatar) { + if (isDestroyed()) return + require(avatar.details.specific) { + "Only avatars that are marked as specific-only can be marked as specific." + } + setSpecific(avatar.details.index) + } + + /** + * Clears the specified NPC's [avatar] as specific-visible. + * @param avatar the NPC avatar whom to unmark as specific-visible. + * @throws IllegalArgumentException if the [avatar] was not allocated as specific-only. + */ + public fun clearSpecific(avatar: NpcAvatar) { + if (isDestroyed()) return + require(avatar.details.specific) { + "Only avatars that are marked as specific-only can be unmarked as specific." + } + unsetSpecific(avatar.details.index) + } + + /** + * Checks whether the [avatar] is specific-visible. + * @param avatar the avatar of the NPC whom to check. + * @return whether the NPC has been marked as specific-visible. + */ + public fun isSpecific(avatar: NpcAvatar): Boolean { + if (isDestroyed()) return false + return isSpecific(avatar.details.index) + } + + /** + * Gets a new instance of an ArrayList containing the indices of all the NPCs that are + * still marked as specific to us. Note that any NPC which was originally marked as + * specific, but got deallocated at some point will not be part of this collection, + * as deallocated NPCs automatically unset as specific on all relevant players. + * + * This function is best used before a player logs out, to clear any associated specific + * NPCs. The returned collection is a new mutable ArrayList - servers are free to + * utilize or mutate this however they want, should they wish to do so. Note that + * this function needs to be called before deallocating NPC info. + * + * @return an ArrayList of NPC indices that are marked as specific and have not yet + * been deallocated from the game. These NPCs may still be in the inaccessible AKA dead state. + */ + public fun getSpecificIndices(): ArrayList { + if (isDestroyed()) return ArrayList(0) + val list = ArrayList(0) + val array = this.specificVisible + for (i in array.indices) { + val vis = array[i] + // Quickly skip over 64 NPCs if there are no specifics + if (vis == 0L) continue + // Otherwise, do a regular length-64 iteration + // While this could be improved with more complicated nextSetBit() computations, + // given the nature of this function and how rarely specific NPCs are actually used, + // it is not worth the hassle. + val start = i * Long.SIZE_BITS + val end = start + Long.SIZE_BITS + for (index in start.. { + return toPacketResult(worldId) + } + + /** + * Turns the previously-computed npc info into a packet instance + * which can be flushed to the client, or an exception if one was thrown while + * building the packet. + * @return the npc packet instance in a [PacketResult]. + */ + @PublishedApi + internal fun toPacketResult(worldId: Int): PacketResult { + val exception = this.exception + if (exception != null) { + return PacketResult.failure( + InfoProcessException( + "Exception occurred during npc info processing for index $localPlayerIndex", + exception, + ), + ) + } + val details = + getDetailsOrNull(worldId) + ?: return PacketResult.failure( + IllegalStateException("World $worldId does not exist."), + ) + val previousPacket = + details.previousPacket + ?: return PacketResult.failure( + IllegalStateException("Previous npc info packet not calculated."), + ) + + return PacketResult.success(previousPacket) + } + + /** + * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. + * The old [NpcInfoWorldDetails.buffer] will not be released, as that is the duty of the encoder class. + */ + @Suppress("DuplicatedCode") + private fun allocBuffer(worldId: Int): ByteBuf { + val details = getDetails(worldId) + // Acquire a new buffer with each cycle, in case the previous one isn't fully written out yet + val buffer = allocator.buffer(BUF_CAPACITY, BUF_CAPACITY) + details.buffer = buffer + details.lastCycleHighResolutionNpcIndexCount = details.highResolutionNpcIndexCount + recycler += buffer + return buffer + } + + /** + * Updates the root world coordinate of the local player. + * @param coordGrid the coordgrid of the player + */ + internal fun updateRootCoord(coordGrid: CoordGrid) { + checkCommunicationThread() + if (isDestroyed()) return + this.localPlayerCurrentCoord = coordGrid + } + + /** + * Computes the high resolution and low resolution bitcodes for this given player, + * additionally marks down which NPCs need to furthermore send their extended info + * updates. + */ + internal fun compute(details: NpcInfoWorldDetails) { + val renderDistance = this.renderDistance + val zoneSearchRadius = this.zoneSearchRadius + val buffer = allocBuffer(details.worldId) + buffer.toBitBuf().use { bitBuffer -> + val fragmented = processHighResolution(details, bitBuffer, renderDistance, zoneSearchRadius) + if (fragmented) { + details.defragmentIndices() + } + if (details.worldId == ROOT_WORLD) { + processRootWorldLowResolution(details, bitBuffer, renderDistance, zoneSearchRadius) + } else { + processLowResolution(details, bitBuffer) + } + // Terminate the low-resolution processing block if there are extended info + // blocks after that; if not, the loop ends naturally due to not enough + // readable bits remaining (at most would have 7 bits remaining due to + // the bit writer closing, which "finishes" the current byte). + if (details.extendedInfoCount > 0) { + bitBuffer.pBits(16, 0xFFFF) + } + } + } + + /** + * Synchronizes the last coordinate of the local player with the current coordinate + * set previously in this cycle. This is simply to help make removal of all NPCs + * in high resolution more efficient, as we can avoid distance checks against every + * NPC, and only do so against the player's last coordinate. + */ + internal fun afterUpdate() { + this.localPlayerLastCoord = this.localPlayerCurrentCoord + for (details in this.details) { + if (details == null) { + continue + } + details.extendedInfoCount = 0 + details.observerExtendedInfoFlags.reset() + + val previousPacket = details.previousPacket + if (previousPacket is ConsumableMessage) { + if (!previousPacket.isConsumed()) { + logger.warn { + "Previous npc info packet was calculated but " + + "not sent out to the client for world ${details.worldId} for player $localPlayerIndex!" + } + } + } + val buffer = backingBuffer(details.worldId) + val isEmpty = isEmptyPacket(details, buffer) + details.previousPacket = + if (details.largeUpdate) { + NpcInfoLargeV5(buffer, isEmpty) + } else { + NpcInfoSmallV5(buffer, isEmpty) + } + + details.largeUpdate = false + } + } + + /** + * Checks if the NPC info packet can be considered as fully empty. + * This means there were no high resolution NPCs in the last cycle, + * nor are there any in this cycle. + * @param details + */ + private fun isEmptyPacket( + details: NpcInfoWorldDetails, + buffer: ByteBuf, + ): Boolean { + // If there were any high resolution NPCs in the last cycle, it cannot be considered empty, + // as it is possible for us to just send the new count as 0 that tells the client to + // clear all high resolution NPCs. + if (details.lastCycleHighResolutionNpcIndexCount != 0) { + return false + } + val readableBytes = buffer.readableBytes() + if (readableBytes == 0) { + return true + } + if (readableBytes > 1) { + return false + } + // Only return true if the new high resolution NPC count is also zero. + return buffer.getByte(buffer.readerIndex()).toInt() == 0 + } + + /** + * Writes the extended info blocks over to the backing buffer, based on the indices + * of the NPCs from whom we requested extended info updates prior in this cycle. + */ + internal fun putExtendedInfo(details: NpcInfoWorldDetails) { + val jagBuffer = backingBuffer(details).toJagByteBuf() + for (i in 0 until details.extendedInfoCount) { + val index = details.extendedInfoIndices[i].toInt() + val other = repository.getOrNull(index) + if (other == null) { + // If other is null at this point, it means it was destroyed mid-processing at an earlier + // stage. In order to avoid the issue escalating further by throwing errors for every player + // that was in vicinity of the NPC that got destroyed, we simply write no-mask-update, + // even though a mask update was requested at an earlier stage. + // The next game tick, the NPC will be removed as the info is null, which is one of + // the conditions for removing a NPC from tracking. + jagBuffer.p1(0) + continue + } + val observerFlag = details.observerExtendedInfoFlags.getFlag(i) + other.extendedInfo.pExtendedInfo( + oldSchoolClientType, + jagBuffer, + localPlayerIndex, + i, + details.extendedInfoCount - i, + observerFlag, + ) + } + } + + /** + * Processes high resolution, existing, NPCs by writing their movements/extended info updates, + * or removes them altogether if need be. + * @param buffer the buffer into which to write the bitcode information. + * @param renderDistance the maximum render distance how far a NPC can be seen. + * If the npc is farther away from the local player than the provided render distance, + * they will be removed from high resolution view. + * @param zoneSearchRadius the number of zones to search for. + * @return whether any high resolution npcs were removed in the middle of the + * array. This does not include the npcs dropped off at the end. + * This is necessary to determine whether we need to defragment the array (ie remove any + * gaps that were produced by removing npcs in the middle of the array). + */ + private fun processHighResolution( + details: NpcInfoWorldDetails, + buffer: BitBuf, + renderDistance: Int, + zoneSearchRadius: Int, + ): Boolean { + // If no one to process, skip + if (details.highResolutionNpcIndexCount == 0) { + buffer.pBits(8, 0) + return false + } + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + // If our coordinate compared to last cycle changed more than 'radius' + // tiles, every NPC in our local view would be removed anyhow, + // so by sending the count as 0, client automatically removes everyone + if (zoneSearchRadius < 0 || + !worldEntityInfo.isVisible( + localPlayerLastCoord, + localPlayerCurrentCoord, + max((zoneSearchRadius shl 3) + 7, renderDistance), + ) + ) { + buffer.pBits(8, 0) + // While it would be more efficient to just... not do this block below, + // the reality is there are ~25k static npcs in the game alone, + // and by tracking the observer counts we can omit computing + // extended info as well as high resolution movement blocks + // for any npc that doesn't have a player near them, + // which, even at full world, will be the majority of npcs. + for (i in 0..= normalSoftCap && + details.lowPriorityCount >= lowCap + ) { + return + } + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val world = worldEntityInfo.getAvatar(details.worldId) ?: return + val encoder = lowResolutionToHighResolutionEncoders[oldSchoolClientType] + val largeDistance = max(world.sizeX, world.sizeZ) > 3 + if (largeDistance) { + details.largeUpdate = true + } + val coord = localPlayerCurrentCoord + // Prefer player's current level if we're updating the world on which they stand + val level = + if (worldEntityInfo.getWorldEntity(coord) == details.worldId) { + coord.level + } else { + world.activeLevel + } + val startX = world.southWestZoneX + val startZ = world.southWestZoneZ + val swCoord = CoordGrid(level, startX shl 3, startZ shl 3) + val endX = world.southWestZoneX + world.sizeX + val endZ = world.southWestZoneZ + world.sizeZ + val filter = this.filter + loop@for (x in startX..= normalSoftCap && details.lowPriorityCount >= lowCap) { + break@loop + } + } else { + // For low priority, if we've reached our cap, just move on - there might be normal + // priority NPCs still coming. + if (details.lowPriorityCount >= lowCap) { + continue + } + } + if (avatar.details.specific) { + if (!isSpecific(index)) { + continue + } + } + if (filter != null && !filter.accept(localPlayerIndex, index)) { + continue + } + avatar.addObserver(localPlayerIndex) + val i = details.highResolutionNpcIndexCount++ + details.incrementPriority( + i, + avatar.details.priorityBitcode and AVATAR_NORMAL_PRIORITY_FLAG == 0, + ) + details.highResolutionNpcIndices[i] = index.toUShort() + val observerFlags = avatar.extendedInfo.getLowToHighResChangeExtendedInfoFlags() + if (observerFlags != 0) { + details.observerExtendedInfoFlags.addFlag(details.extendedInfoCount, observerFlags) + } + val extendedInfo = (avatar.extendedInfo.flags or observerFlags) != 0 + if (extendedInfo) { + details.extendedInfoIndices[details.extendedInfoCount++] = index.toUShort() + } + encoder.encode( + buffer, + avatar.details, + extendedInfo, + swCoord, + largeDistance, + NpcInfoProtocol.cycleCount, + ) + } + } + } + } + + private fun processRootWorldLowResolution( + details: NpcInfoWorldDetails, + buffer: BitBuf, + renderDistance: Int, + zoneSearchRadius: Int, + ) { + val lowCap = details.lowPriorityCap + val normalSoftCap = details.normalPrioritySoftCap + // If our local view is already maxed out, don't even bother calculating the below + if (details.normalPriorityCount >= normalSoftCap && + details.lowPriorityCount >= lowCap || + zoneSearchRadius < 0 + ) { + return + } + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val encoder = lowResolutionToHighResolutionEncoders[oldSchoolClientType] + val largeDistance = zoneSearchRadius > 3 + if (largeDistance) { + details.largeUpdate = true + } + val rootWorldCoord = worldEntityInfo.getCoordGridInRootWorld(localPlayerCurrentCoord) + val centerX = rootWorldCoord.x + val centerZ = rootWorldCoord.z + val level = rootWorldCoord.level + val startX = ((centerX shr 3) - zoneSearchRadius).coerceAtLeast(0) + val startZ = ((centerZ shr 3) - zoneSearchRadius).coerceAtLeast(0) + val endX = ((centerX shr 3) + zoneSearchRadius).coerceAtMost(0x7FF) + val endZ = ((centerZ shr 3) + zoneSearchRadius).coerceAtMost(0x7FF) + val filter = this.filter + loop@for (x in startX..endX) { + for (z in startZ..endZ) { + val npcs = this.zoneIndexStorage.get(level, x, z) ?: continue + for (k in 0..= normalSoftCap && details.lowPriorityCount >= lowCap) { + break@loop + } + } else { + // For low priority, if we've reached our cap, just move on - there might be normal + // priority NPCs still coming. + if (details.lowPriorityCount >= lowCap) { + continue + } + } + if (!worldEntityInfo.isVisibleInRoot( + rootWorldCoord, + avatar.details.currentCoord, + max(renderDistance, avatar.details.renderDistance), + ) + ) { + continue + } + if (avatar.details.specific) { + if (!isSpecific(index)) { + continue + } + } + if (filter != null && !filter.accept(localPlayerIndex, index)) { + continue + } + avatar.addObserver(localPlayerIndex) + val i = details.highResolutionNpcIndexCount++ + details.incrementPriority( + i, + avatar.details.priorityBitcode and AVATAR_NORMAL_PRIORITY_FLAG == 0, + ) + details.highResolutionNpcIndices[i] = index.toUShort() + val observerFlags = avatar.extendedInfo.getLowToHighResChangeExtendedInfoFlags() + if (observerFlags != 0) { + details.observerExtendedInfoFlags.addFlag(details.extendedInfoCount, observerFlags) + } + val extendedInfo = (avatar.extendedInfo.flags or observerFlags) != 0 + if (extendedInfo) { + details.extendedInfoIndices[details.extendedInfoCount++] = index.toUShort() + } + encoder.encode( + buffer, + avatar.details, + extendedInfo, + rootWorldCoord, + largeDistance, + NpcInfoProtocol.cycleCount, + ) + } + } + } + } + + /** + * Checks whether a npc by the index of [index] is already within our high resolution + * view. + * @param index the index of the npc to check + * @return whether the npc at the given index is already in high resolution. + */ + private fun isHighResolution( + details: NpcInfoWorldDetails, + index: Int, + ): Boolean { + // NOTE: Perhaps it's more efficient to just allocate 65535 bits and do a bit check? + // Would cost ~16.76mb at max world capacity + for (i in 0.., + avatarFactory: NpcAvatarFactory, + private val exceptionHandler: NpcAvatarExceptionHandler, + private val worker: ProtocolWorker = DefaultProtocolWorker(), + private val zoneIndexStorage: ZoneIndexStorage, + private val filter: NpcAvatarFilter? = null, +) { + private val detailsStorage: NpcInfoWorldDetailsStorage = NpcInfoWorldDetailsStorage() + private val recycler: ByteBufRecycler = ByteBufRecycler() + + /** + * The avatar repository keeps track of all the avatars currently in the game. + */ + private val avatarRepository = avatarFactory.avatarRepository + + /** + * Npc info repository keeps track of the main npc info objects which are allocated + * by players at a 1:1 ratio. + */ + private val npcInfoRepository: NpcInfoRepository = + NpcInfoRepository { localIndex, clientType, worldEntityInfo -> + NpcInfo( + allocator, + avatarRepository, + clientType, + localIndex, + zoneIndexStorage, + resolutionChangeEncoders, + detailsStorage, + recycler, + filter, + worldEntityInfo, + ) + } + + /** + * The list of [Callable] instances which perform the jobs for player info. + * This list itself is re-used throughout the lifespan of the application, + * but the [Callable] instances themselves are generated for every job. + */ + private val callables: MutableList> = ArrayList(PROTOCOL_CAPACITY) + + /** + * Allocates a new npc info object, or re-uses an older one if possible. + * @param idx the index of the player allocating the npc info object. + * @param oldSchoolClientType the client on which the player has logged into. + */ + internal fun alloc( + idx: Int, + oldSchoolClientType: OldSchoolClientType, + worldEntityInfo: WorldEntityInfo, + ): NpcInfo { + checkCommunicationThread() + return npcInfoRepository.alloc(idx, oldSchoolClientType, worldEntityInfo) + } + + /** + * Deallocates the provided npc info object, allowing it to be used up + * by another player in the future. + * @param info the npc info object to deallocate + */ + internal fun dealloc(info: NpcInfo) { + checkCommunicationThread() + // Prevent returning a destroyed npc info object back into the pool + if (info.isDestroyed()) { + return + } + npcInfoRepository.dealloc(info.localPlayerIndex) + } + + /** + * Gets the npc info at the provided index. + * @param idx the index of the npc info + * @return npc info object at that index + * @throws IllegalStateException if the npc info is null at that index + * @throws ArrayIndexOutOfBoundsException if the index is out of bounds + */ + public operator fun get(idx: Int): NpcInfo { + checkCommunicationThread() + return npcInfoRepository[idx] + } + + /** + * Gets the npc info at the provided index, or null if it doesn't exist. + * @param idx the index of the npc info + * @return npc info object at that index + */ + public fun getOrNull(idx: Int): NpcInfo? { + checkCommunicationThread() + return npcInfoRepository.getOrNull(idx) + } + + /** + * Updates the npc info protocol for this cycle. + * The jobs here will be executed according to the [worker] specified, + * allowing multithreaded execution if selected. + */ + public fun update() { + checkCommunicationThread() + synchronizeModifiedWorlds() + prepareBitcodes() + putBitcodes() + prepareExtendedInfo() + putExtendedInfo() + postUpdate() + recycler.cycle() + cycleCount++ + } + + /** + * Prepares the high resolution bitcodes of all the NPC avatars which have + * at least one observer. + */ + private fun prepareBitcodes() { + for (i in 0.. high resolution changes + if (!avatar.isActive()) { + extendedInfo.precomputeCached() + } else { + extendedInfo.precompute() + } + } catch (e: Exception) { + exceptionHandler.exceptionCaught(i, e) + } catch (t: Throwable) { + logger.error(t) { + "Error during npc extended info preparation" + } + throw t + } + } + } + + /** + * Synchronizes the worlds for every player, by destroying any details of the worlds + * no longer in view, and allocating any details of the worlds newly added. + */ + private fun synchronizeModifiedWorlds() { + execute { + synchronizeWorlds() + } + } + + /** + * Writes the bitcodes of npc info objects over into the buffer. + * The work is split across according to the [worker] specified. + */ + private fun putBitcodes() { + execute { + for (details in this.details) { + if (details == null) { + continue + } + compute(details) + } + } + } + + /** + * Writes the extended info blocks over into the buffer. + * The work is split across according to the [worker] specified. + */ + private fun putExtendedInfo() { + execute { + for (details in this.details) { + if (details == null) { + continue + } + putExtendedInfo(details) + } + } + } + + /** + * Cleans up any single-cycle temporary information for npc info protocol. + */ + private fun postUpdate() { + execute { + afterUpdate() + } + for (i in 0.. Unit) { + for (i in 1.. NpcInfo, +) : InfoRepository(allocator) { + override val elements: Array = arrayOfNulls(NpcInfoProtocol.PROTOCOL_CAPACITY) + + override fun informDeallocation(idx: Int) { + // No-op + } + + override fun onDealloc(element: NpcInfo) { + element.onDealloc() + } + + override fun onAlloc( + element: NpcInfo, + idx: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) { + element.onAlloc(idx, oldSchoolClientType, newInstance) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoSmallV5.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoSmallV5.kt new file mode 100644 index 000000000..1f19c1da5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoSmallV5.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import io.netty.buffer.ByteBuf + +/** + * A small npc info wrapper packet, used to wrap the pre-built buffer from the npc info class. + */ +public class NpcInfoSmallV5( + buffer: ByteBuf, + empty: Boolean, +) : NpcInfoPacket(buffer, empty) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + return true + } + + override fun hashCode(): Int { + return super.hashCode() + } + + override fun toString(): String { + return "NpcInfoSmallV5()" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoTypealiases.kt new file mode 100644 index 000000000..64529c0b9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoTypealiases.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +@Deprecated( + message = "Deprecated. Use NpcInfoSmallV5.", + replaceWith = ReplaceWith("NpcInfoSmallV5"), +) +public typealias NpcInfoSmall = NpcInfoSmallV5 + +@Deprecated( + message = "Deprecated. Use NpcInfoLargeV5.", + replaceWith = ReplaceWith("NpcInfoLargeV5"), +) +public typealias NpcInfoLarge = NpcInfoLargeV5 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetails.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetails.kt new file mode 100644 index 000000000..2a95c1779 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetails.kt @@ -0,0 +1,310 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import io.netty.buffer.ByteBuf +import net.rsprot.protocol.game.outgoing.info.ObserverExtendedInfoFlags + +/** + * A world detail implementation for NPC info, tracking local NPCs in a specific world. + * @property worldId the id of the world in which the NPCs exist. + */ +@OptIn(ExperimentalUnsignedTypes::class) +internal class NpcInfoWorldDetails( + internal var worldId: Int, +) { + /** + * The maximum number of NPCs that can render at once in the + * [net.rsprot.protocol.game.outgoing.info.AvatarPriority.LOW] priority group. + */ + internal var lowPriorityCap: Int = MAX_HIGH_RESOLUTION_NPCS + + /** + * The current number of NPCs occupying the low priority group. + */ + internal var lowPriorityCount: Int = 0 + + /** + * The maximum number of NPCs that can render at once in the + * [net.rsprot.protocol.game.outgoing.info.AvatarPriority.NORMAL] priority group. + * Note that any normal priority NPC will be able to make use of [lowPriorityCap] if the + * normal priority soft cap has no more free slots. + */ + internal var normalPrioritySoftCap: Int = 0 + + /** + * The current number of NPCs occupying the normal priority group. + */ + internal var normalPriorityCount: Int = 0 + + /** + * Priority flags for currently tracked NPCs, one bit per possible high resolution slot. + * A flag of 0x1 implies low priority; if the flag isn't set, that NPC is in normal priority. + */ + private var highResolutionPriorityFlags = LongArray(3) + + /** + * A secondary array of high resolution priority flags. + * This does the same as [highResolutionPriorityFlags], but because a defragmentation process + * can take place, we need to re-sort the values. + */ + private var temporaryHighResolutionPriorityFlags = LongArray(3) + + /** + * The indices of the high resolution NPCs, in the order as they came in. + * This is a replica of how the client keeps track of NPCs. + */ + internal var highResolutionNpcIndices: UShortArray = + UShortArray(MAX_HIGH_RESOLUTION_NPCS) { + NPC_INDEX_TERMINATOR + } + + /** + * A secondary array for high resolution NPCs. + * After each cycle, the [highResolutionNpcIndices] gets swapped with this property, + * and the indices will be appended one by one. As a result of it, we can get away + * with significantly fewer operations to defragment the array, as we don't have to + * shift every entry over, we only need to fill in the ones that still exist. + */ + private var temporaryHighResolutionNpcIndices: UShortArray = + UShortArray(MAX_HIGH_RESOLUTION_NPCS) { + NPC_INDEX_TERMINATOR + } + + /** + * A counter for how many high resolution NPCs are currently being tracked. + * This count cannot exceed [MAX_HIGH_RESOLUTION_NPCS], as the client + * only supports that many extended info updates. + */ + internal var highResolutionNpcIndexCount: Int = 0 + + /** + * The extended info indices contain pointers to all the npcs for whom we need to + * write an extended info block. We do this rather than directly writing them as this + * improves CPU cache locality and allows us to batch extended info blocks together. + */ + internal val extendedInfoIndices: UShortArray = UShortArray(MAX_HIGH_RESOLUTION_NPCS) + + /** + * The number of npcs for whom we need to write extended info blocks this cycle. + */ + internal var extendedInfoCount: Int = 0 + + /** + * The observer extended info flags are a means to track which extended info blocks + * we need to transmit when moving a NPC from low resolution to high resolution, + * as there are numerous extended info blocks which hold state over a long period + * of time, such as head icon changes - if we didn't do this, anyone that observes + * a NPC after the cycle during which the head icons were set, would not see these + * head icons. + */ + internal val observerExtendedInfoFlags: ObserverExtendedInfoFlags = + ObserverExtendedInfoFlags(MAX_HIGH_RESOLUTION_NPCS) + + /** + * The primary npc info buffer, holding all the bitcodes and extended info blocks. + */ + internal var buffer: ByteBuf? = null + + /** + * The number of high resolution NPCs in the last cycle. + */ + internal var lastCycleHighResolutionNpcIndexCount: Int = 0 + + /** + * Whether the packet carries a large npc info update. + */ + internal var largeUpdate: Boolean = false + + /** + * The previous npc info packet that was created. + * We ensure that a server hasn't accidentally left a packet unwritten, which would + * de-synchronize the client and cause errors. + */ + internal var previousPacket: NpcInfoPacket? = null + + /** + * Performs an index defragmentation on the [highResolutionNpcIndices] array. + * This function will effectively take all indices that are NOT [NPC_INDEX_TERMINATOR] + * and put them into the [temporaryHighResolutionNpcIndices] in a consecutive order, + * without gaps. Afterwards, the [temporaryHighResolutionNpcIndices] and [highResolutionNpcIndices] + * arrays get swapped out, so our [highResolutionNpcIndices] becomes a defragmented array. + * This process occurs every cycle, after high resolution indices are processed, in order to + * get rid of any gaps that were produced as a result of it. + * + * A breakdown of this process: + * At the start of a cycle, we might have indices as `[1, 7, 5, 3, 8, 65535, ...]` + * If we make the assumption that NPCs at indices 7 and 8 are being removed from our high resolution, + * during the high resolution processing, npc at index 8 is dropped naturally - this is because + * the client will automatically trim off any NPCs at the end which don't fit into the transmitted + * count. So, npc at index 8 does not count towards fragmentation, as we just decrement the index count. + * However, index 7, because it is in the middle of this array of indices, causes the array + * to fragment. So in order to resolve this, we will iterate the fragmented indices + * until we have collected [highResolutionNpcIndexCount] worth of valid indices into the + * [temporaryHighResolutionNpcIndices] array. + * After defragmenting, our array will look as `[1, 5, 3, 65535, ...]`. + * While it is possible to do this with a single array, it requires one to shift every element + * in the array after the first fragmentation occurs. As the arrays are relatively small, it's + * better simply to use two arrays that get swapped every cycle, so we simply swap + * the [temporaryHighResolutionNpcIndices] and [highResolutionNpcIndices] arrays between one another, + * rather than needing to shift everything over. + */ + internal fun defragmentIndices() { + var count = 0 + for (i in highResolutionNpcIndices.indices) { + if (count >= highResolutionNpcIndexCount) { + break + } + val index = highResolutionNpcIndices[i] + if (index != NPC_INDEX_TERMINATOR) { + temporaryHighResolutionNpcIndices[count] = index + if (isLowPriority(i)) { + setLowPriority(count, temporaryHighResolutionPriorityFlags) + } + count++ + } + } + val uncompressed = this.highResolutionNpcIndices + this.highResolutionNpcIndices = this.temporaryHighResolutionNpcIndices + this.temporaryHighResolutionNpcIndices = uncompressed + + val priorities = this.highResolutionPriorityFlags + this.highResolutionPriorityFlags = this.temporaryHighResolutionPriorityFlags + this.temporaryHighResolutionPriorityFlags = priorities + this.temporaryHighResolutionPriorityFlags.fill(0L) + } + + /** + * Decrements the priority counters for the given high resolution slot [index]. + * @param index the high resolution (not absolute) index of the NPC, a value from 0 to 149. + */ + internal fun decrementPriority(index: Int) { + if (isLowPriority(index)) { + unsetLowPriority(index) + if (lowPriorityCount > 0) { + --lowPriorityCount + } + } else { + if (normalPriorityCount > 0) { + --normalPriorityCount + } + } + } + + /** + * Increments the priority for a given slot [index]. + * If the NPC is not low priority, but our normal priority group is full, we instead + * assign that NPC to the low priority group. + * @param index the high resolution (not absolute) index of the NPC, a value from 0 to 149. + * @param isLowPriority whether the NPC belongs in the low priority category. + */ + internal fun incrementPriority( + index: Int, + isLowPriority: Boolean, + ) { + if (isLowPriority || (normalPriorityCount >= normalPrioritySoftCap)) { + setLowPriority(index) + lowPriorityCount++ + } else { + normalPriorityCount++ + } + } + + /** + * Checks whether the npc at high resolution slot [index] is in the low priority group. + * @param index the high resolution (not absolute) index of the NPC, a value from 0 to 149. + * @param array the array from which to check whether a bit is set. + */ + private fun isLowPriority( + index: Int, + array: LongArray = this.highResolutionPriorityFlags, + ): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + return array[longIndex] and bit != 0L + } + + /** + * Marks the npc at high resolution slot [index] as low priority. + * @param index the high resolution (not absolute) index of the NPC, a value from 0 to 149. + * @param array the array in which to modify the corresponding bit. + */ + internal fun setLowPriority( + index: Int, + array: LongArray = this.highResolutionPriorityFlags, + ) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = array[longIndex] + array[longIndex] = cur or bit + } + + /** + * Unmarks the npc at high resolution slot [index] as low priority. + * @param index the high resolution (not absolute) index of the NPC, a value from 0 to 149. + * @param array the array in which to modify the corresponding bit. + */ + internal fun unsetLowPriority( + index: Int, + array: LongArray = this.highResolutionPriorityFlags, + ) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = array[longIndex] + array[longIndex] = cur and bit.inv() + } + + /** + * Clears any priority flags currently set and resets the priority counts to zero. + */ + internal fun clearPriorities() { + this.highResolutionPriorityFlags.fill(0L) + this.temporaryHighResolutionPriorityFlags.fill(0L) + this.lowPriorityCount = 0 + this.normalPriorityCount = 0 + } + + /** + * Resets all the properties of this world details implementation, allowing + * it to be re-used for another player. + * @param worldId the new world id to be used for these details. + */ + internal fun onAlloc(worldId: Int) { + this.worldId = worldId + this.highResolutionNpcIndexCount = 0 + this.highResolutionNpcIndices.fill(0u) + this.temporaryHighResolutionNpcIndices.fill(0u) + this.extendedInfoCount = 0 + this.extendedInfoIndices.fill(0u) + this.observerExtendedInfoFlags.reset() + this.highResolutionPriorityFlags.fill(0L) + this.temporaryHighResolutionPriorityFlags.fill(0L) + this.lowPriorityCap = MAX_HIGH_RESOLUTION_NPCS + this.normalPrioritySoftCap = 0 + this.lowPriorityCount = 0 + this.normalPriorityCount = 0 + this.buffer = null + this.previousPacket = null + this.lastCycleHighResolutionNpcIndexCount = 0 + this.largeUpdate = false + } + + internal fun onDealloc() { + this.buffer = null + this.previousPacket = null + } + + private companion object { + /** + * The maximum number of high resolution NPCs that the client supports, limited by the + * client's array of extended info updates being a size-149 int array. + * Starting with revision 229, on Java clients, the limit is 149, and OSRS + * does indeed only send 149 at most - tested via toy cats. On native, the limit + * is still 250. + */ + private const val MAX_HIGH_RESOLUTION_NPCS: Int = 149 + + /** + * The terminator value used to indicate that no NPC is here. + */ + private const val NPC_INDEX_TERMINATOR: UShort = 0xFFFFu + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetailsStorage.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetailsStorage.kt new file mode 100644 index 000000000..4d3eb8136 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/NpcInfoWorldDetailsStorage.kt @@ -0,0 +1,39 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import java.lang.ref.ReferenceQueue +import java.lang.ref.SoftReference + +/** + * A storage object for npc info world details. + * As these detail objects are fairly large, with each one making several arrays + * that are thousands in length, it is preferred to pool and re-use these whenever possible. + * @property queue the soft reference queue holding these objects. + */ +internal class NpcInfoWorldDetailsStorage { + private val queue: ReferenceQueue = ReferenceQueue() + + /** + * Polls a world from the queue, or creates a new one. + * @param worldId the id of the world to assign to the details. + * @return an unused world details implementation. + */ + internal fun poll(worldId: Int): NpcInfoWorldDetails { + val next = queue.poll()?.get() + if (next != null) { + next.onAlloc(worldId) + return next + } + return NpcInfoWorldDetails(worldId) + } + + /** + * Pushes a world that's now unused back into the queue, allowing it to be re-used + * by someone else in the future. + * @param details the object containing the implementation details. + */ + internal fun push(details: NpcInfoWorldDetails) { + details.onDealloc() + val reference = SoftReference(details, queue) + reference.enqueue() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/SetNpcUpdateOrigin.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/SetNpcUpdateOrigin.kt new file mode 100644 index 000000000..ea05bb7e4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/SetNpcUpdateOrigin.kt @@ -0,0 +1,66 @@ +package net.rsprot.protocol.game.outgoing.info.npcinfo + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * The set npc update origin packet is used to set the relative coordinate for npc info packet. + * As of revision 222, with the introduction of world entities, it is no longer viable to solely + * rely on the local player's coordinate, as it may be impacted by a specific world entity. + * As such, npc info updates should now be prefaced with the origin update to mark the relative coord. + * For no-world-entity use cases, just pass the player's coordinate in the current build area to + * get the old behavior. + * + * @property originX the x coordinate within the current build area of the player relative + * to which NPCs will be placed within NPC info packet. + * @property originZ the z coordinate within the current build area of the player relative + * to which NPCs will be placed within NPC info packet. + */ +public class SetNpcUpdateOrigin private constructor( + private val _originX: UByte, + private val _originZ: UByte, +) : OutgoingGameMessage { + public constructor( + originX: Int, + originZ: Int, + ) : this( + originX.toUByte(), + originZ.toUByte(), + ) + + public val originX: Int + get() = _originX.toInt() + public val originZ: Int + get() = _originZ.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetNpcUpdateOrigin + + if (_originX != other._originX) return false + if (_originZ != other._originZ) return false + + return true + } + + override fun hashCode(): Int { + var result = _originX.hashCode() + result = 31 * result + _originZ.hashCode() + return result + } + + override fun toString(): String = + "SetNpcUpdateOrigin(" + + "originX=$originX, " + + "originZ=$originZ" + + ")" + + public companion object { + public val DYNAMIC: SetNpcUpdateOrigin = SetNpcUpdateOrigin(0, 0) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/util/NpcCellOpcodes.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/util/NpcCellOpcodes.kt new file mode 100644 index 000000000..dcafced12 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/npcinfo/util/NpcCellOpcodes.kt @@ -0,0 +1,64 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.info.npcinfo.util + +internal object NpcCellOpcodes { + private const val NW: Int = 0 + private const val N: Int = 1 + private const val NE: Int = 2 + private const val W: Int = 3 + private const val E: Int = 4 + private const val SW: Int = 5 + private const val S: Int = 6 + private const val SE: Int = 7 + + /** + * Single cell movement opcodes in a len-16 array. + */ + private val singleCellMovementOpcodes: IntArray = buildSingleCellMovementOpcodes() + + /** + * Gets the index for a single cell movement opcode based on the deltas, + * where the deltas are expected to be either -1, 0 or 1. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the index of the single cell opcode stored in [singleCellMovementOpcodes] + */ + private fun singleCellIndex( + deltaX: Int, + deltaZ: Int, + ): Int = (deltaX + 1).or((deltaZ + 1) shl 2) + + /** + * Gets the single cell movement opcode value for the provided deltas. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the movement opcode as expected by the client, or -1 if the deltas are in range, + * but the deltas do not result in any movement. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -1..1. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + internal fun singleCellMovementOpcode( + deltaX: Int, + deltaZ: Int, + ): Int = singleCellMovementOpcodes[singleCellIndex(deltaX, deltaZ)] + + /** + * Builds a simple bitpacked array of the bit codes for all the possible deltas. + * This is simply a more efficient variant of the normal if-else chain of checking + * the different delta combinations, as we are skipping a lot of branch prediction. + * In a benchmark, the results showed ~603% increased performance. + */ + private fun buildSingleCellMovementOpcodes(): IntArray { + val array = IntArray(16) { -1 } + array[singleCellIndex(-1, -1)] = SW + array[singleCellIndex(0, -1)] = S + array[singleCellIndex(1, -1)] = SE + array[singleCellIndex(-1, 0)] = W + array[singleCellIndex(1, 0)] = E + array[singleCellIndex(-1, 1)] = NW + array[singleCellIndex(0, 1)] = N + array[singleCellIndex(1, 1)] = NE + return array + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/GlobalLowResolutionPositionRepository.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/GlobalLowResolutionPositionRepository.kt new file mode 100644 index 000000000..35d03651f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/GlobalLowResolutionPositionRepository.kt @@ -0,0 +1,125 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import net.rsprot.buffer.bitbuffer.UnsafeLongBackedBitBuf +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol.Companion.PROTOCOL_CAPACITY +import net.rsprot.protocol.game.outgoing.info.playerinfo.util.CellOpcodes +import net.rsprot.protocol.game.outgoing.info.playerinfo.util.LowResolutionPosition +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import kotlin.math.abs + +/** + * A repository used to track the low resolution positions of all the player avatars in the world. + * These low resolution positions will be synchronized to all the players, as part of the protocol. + * As all observers receive the same information, rather than allocating this per-player basis, + * we do it once, globally, for the entire world. + */ +internal class GlobalLowResolutionPositionRepository { + /** + * The low resolution positions of all the players in the previous cycle. + */ + private val previousLowResPositions: IntArray = IntArray(PROTOCOL_CAPACITY) + + /** + * The low resolution positions of all the players in the current cycle. + */ + private val currentLowResPositions: IntArray = IntArray(PROTOCOL_CAPACITY) + + /** + * An array of precalculated low resolution buffers. These must be stored off of player info objects, + * as we need to still send them when a player logs out. If the buffer is null, there is no + * low resolution update happening for that player. + */ + private val buffers: Array = arrayOfNulls(PROTOCOL_CAPACITY) + + /** + * Updates the current low resolution position of the player at index [idx]. + * @param idx the index of the player whose low resolution position to update. + * @param coordGrid the new absolute coordinate of that player. The low resolution + * coordinate will be calculated out of it. + */ + internal fun update( + idx: Int, + coordGrid: CoordGrid, + ) { + val lowResolutionPosition = LowResolutionPosition(coordGrid) + currentLowResPositions[idx] = lowResolutionPosition.packed + } + + /** + * Marks the player at index [idx] as unused. This should be done whenever a player logs out. + * @param idx the index of the player. + */ + internal fun markUnused(idx: Int) { + currentLowResPositions[idx] = 0 + } + + /** + * Prepares the low resolution buffer for the player at index [idx]. + * @param idx the index of the player to prepare the low resolution buffer for. + */ + internal fun prepareLowResBuffer(idx: Int) { + this.buffers[idx] = calculateLowResolutionBuffer(idx) + } + + /** + * Calculates the low resolution buffer for player at index [idx], or null if there was no + * change in their low resolution coordinate. + * @param idx the index of the player to calculate for. + * @return a bitpacked buffer containing the low resolution coordinate. + */ + private fun calculateLowResolutionBuffer(idx: Int): UnsafeLongBackedBitBuf? { + val old = getPreviousLowResolutionPosition(idx) + val cur = getCurrentLowResolutionPosition(idx) + if (old == cur) { + return null + } + val buffer = UnsafeLongBackedBitBuf() + val deltaX = cur.x - old.x + val deltaZ = cur.z - old.z + val deltaLevel = cur.level - old.level + if (deltaX == 0 && deltaZ == 0) { + buffer.pBits(2, 1) + buffer.pBits(2, deltaLevel) + } else if (abs(deltaX) <= 1 && abs(deltaZ) <= 1) { + buffer.pBits(2, 2) + buffer.pBits(2, deltaLevel) + buffer.pBits(3, CellOpcodes.singleCellMovementOpcode(deltaX, deltaZ)) + } else { + buffer.pBits(2, 3) + buffer.pBits(2, deltaLevel) + buffer.pBits(8, deltaX and 0xFF) + buffer.pBits(8, deltaZ and 0xFF) + } + return buffer + } + + /** + * Gets the low resolution buffer for the player at index [idx], or null if there was + * no low resolution update for that player. + */ + internal fun getBuffer(idx: Int): UnsafeLongBackedBitBuf? = buffers[idx] + + /** + * Gets the previous cycle's low resolution position of the player at index [index]. + * @param index the index of the player + * @return the low resolution position of that player in the last cycle. + */ + internal fun getPreviousLowResolutionPosition(index: Int): LowResolutionPosition = + LowResolutionPosition(previousLowResPositions[index]) + + /** + * Gets the current cycle's low resolution position of the player at index [index]. + * @param index the index of the player + * @return the low resolution position of that player in the current cycle. + */ + internal fun getCurrentLowResolutionPosition(index: Int): LowResolutionPosition = + LowResolutionPosition(currentLowResPositions[index]) + + /** + * Synchronize the low resolution positions at the end of the cycle. + * This function will move all current positions over to the previous cycle. + */ + internal fun postUpdate() { + currentLowResPositions.copyInto(previousLowResPositions) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/ObservedChatStorage.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/ObservedChatStorage.kt new file mode 100644 index 000000000..68d4f573a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/ObservedChatStorage.kt @@ -0,0 +1,172 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Chat +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say + +/** + * A storage for any chat messages a player observed, in the exact order they appeared + * in the player's client. This allows for a precise reconstruction of the chat logs + * as one would have seen things. + * @property captureChat whether to capture 'Chat' messages + * @property captureSay whether to capture 'Say' messages + * @property count the number of messages tracked so far in the current tick. + * @property limit the maximum theoretical limit of messages in one tick. + * @property messages an array of messages in this current tick. + */ +public class ObservedChatStorage( + private val captureChat: Boolean, + private val captureSay: Boolean, +) { + private var count: Int = 0 + private val limit: Int + get() = (if (captureChat) 2048 else 0) + (if (captureSay) 2048 else 0) + private val messages: Array = arrayOfNulls(limit) + + /** + * Captures a snapshot of the messages in the current tick, returning the list of them + * in the exact order they were received. + * @return a list of chat messages the player observed. + */ + public fun snapshot(): List { + if (count == 0) return emptyList() + val list = ArrayList(count) + for (i in 0..>, + huffmanCodec: HuffmanCodecProvider, +) : Avatar { + /** + * The index of our local player. + */ + public var localPlayerIndex: Int = localIndex + internal set + + /** + * The preferred resize range. The player information protocol will attempt to + * add everyone within [preferredResizeRange] tiles to high resolution. + * If [preferredResizeRange] is equal to [Int.MAX_VALUE], resizing will be disabled + * and everyone will be put to high resolution. The extended information may be + * disabled for these players as a result, to avoid buffer overflows. + */ + internal var preferredResizeRange: Int = DEFAULT_RESIZE_RANGE + + /** + * The current range at which other players can be observed. + * By default, this value is equal to 15 game squares, however, it may dynamically + * decrease if there are too many high resolution players nearby. It will naturally + * restore back to the default size when the pressure starts to decrease. + */ + internal var resizeRange: Int = preferredResizeRange + + /** + * The current cycle counter for resizing logic. + * Resizing by default will occur after every ten cycles. Once the + * protocol begins decrementing the range, it will continue to do so + * every cycle until it reaches a low enough pressure point. + * Every 11th cycle from thereafter, it will attempt to increase it back. + * If it succeeds, it will continue to do so every cycle, similarly to decreasing. + * If it however fails, it will set the range lower by one tile and remain there + * for the next ten cycles. + */ + private var resizeCounter: Int = DEFAULT_RESIZE_INTERVAL + + /** + * The maximum local player count that the protocol will try to stay under. + * The default value is 250. + */ + private var preferredPlayerCount: Int = DEFAULT_PREFERRED_PLAYER_COUNT + + /** + * The interval in game cycles in which the resizing logic is re-attempted after failure. + * The default value is 10 game cycles. + */ + private var preferredResizeInterval: Int = DEFAULT_RESIZE_INTERVAL + + /** + * The current known coordinate of the given player. + * The coordinate property will need to be updated for all players prior to computing + * player info packet for any of them. + */ + public var currentCoord: CoordGrid = CoordGrid.INVALID + private set + + /** + * The default priority for player avatars, defaulting to [AvatarPriority.LOW]. + * If set to [AvatarPriority.NORMAL], this avatar will be rendered to everyone + * within the [preferredResizeRange], regardless of if [resizeRange] itself + * has decreased. This is noticeable particularly in highly populated areas. + * Developers can use this feature to give higher priority to staff members and + * other important individuals, ensuring that even when there are >= 250 players + * around, these important individuals will render to everyone. + */ + internal var priority: AvatarPriority = AvatarPriority.LOW + + /** + * The last known coordinate of this player. This property will be used in conjunction + * with [currentCoord] to determine the coordinate delta, which is then transmitted + * to the clients. + */ + internal var lastCoord: CoordGrid = CoordGrid.INVALID + + /** + * Extended info repository, commonly referred to as "masks", will track everything relevant + * inside itself. Setting properties such as a spotanim would be done through this. + * The [extendedInfo] is also responsible for caching the non-temporary blocks, + * such as appearance and move speed. + */ + public val extendedInfo: PlayerAvatarExtendedInfo = + PlayerAvatarExtendedInfo( + localIndex, + extendedInfoFilter, + extendedInfoWriters, + allocator, + huffmanCodec, + ) + + /** + * Whether this avatar is completed hidden from everyone else. Note that this completely skips + * sending any information to the client about this given avatar, it is not the same as soft + * hiding via the appearance extended info. + * The benefit to this function is that no plugins or RuneLite implementations can snoop on other + * players that are meant to be hidden. The downside, however, is that because the client has no + * knowledge of that specific avatar whatsoever, un-hiding while the player is moving is not as + * smooth as with the appearance variant, since it first appears as if the player teleported in. + */ + public var hidden: Boolean = false + set(value) { + checkCommunicationThread() + field = value + } + + /** + * The [PlayerInfoProtocol.cycleCount] when this avatar was allocated. + * We use this to determine whether to perform a re-synchronization of a player, + * which can happen when a player is deallocated and reallocated on the same cycle, + * which could result in other players not seeing any change take place. While rare, + * this possibility exists, and it could result in some rather odd bugs. + */ + internal var allocateCycle: Int = PlayerInfoProtocol.cycleCount + + /** + * Resets all the properties of the given avatar to their default values. + */ + internal fun reset() { + preferredResizeRange = DEFAULT_RESIZE_RANGE + resizeRange = preferredResizeRange + resizeCounter = DEFAULT_RESIZE_INTERVAL + preferredPlayerCount = DEFAULT_PREFERRED_PLAYER_COUNT + preferredResizeInterval = DEFAULT_RESIZE_INTERVAL + currentCoord = CoordGrid.INVALID + lastCoord = CoordGrid.INVALID + } + + /** + * Updates the current known coordinate of the given [PlayerAvatar]. + * This function must be called on each avatar before player info is computed. + * @param coordGrid the root world coordgrid of the player + */ + internal fun updateCoord(coordGrid: CoordGrid) { + checkCommunicationThread() + this.currentCoord = coordGrid + } + + /** + * Updates the previous cycle's coordinate to be the current cycle's coordinate. + * This is called at the end of the cycle, to prepare for the next cycle. + */ + override fun postUpdate() { + this.lastCoord = currentCoord + } + + /** + * Sets the preferred resize range, effectively how far to render players from. + * The preferred bit here means that it can resize down if there are too many + * players around. + * @param range the range from which to render other players. + */ + public fun setPreferredResizeRange(range: Int) { + checkCommunicationThread() + require(range < Int.MAX_VALUE) { + "Cannot set preferred resize range to max int (special behaviour) - use #forceResizeRange." + } + this.preferredResizeRange = range + this.resizeRange = range + } + + /** + * Forces the resize range to [range] while disabling the auto resizing feature. + * Note that if the value is [Int.MAX_VALUE], all distance checks will be disabled. + * This is intended for heatmaps, where you'd need all players regardless of where they + * are to be transmitted to your client. + * @param range the range from which to render other players. + */ + public fun forceResizeRange(range: Int) { + checkCommunicationThread() + this.resizeRange = range + this.preferredResizeRange = Int.MAX_VALUE + } + + /** + * Sets the preferred player count limit. This is the maximum amount of players + * that the protocol will try to render at any one time. It is a soft limit + * and can be exceeded. The default value is 250. + * @param limit the new preferred player count limit to assign. + */ + public fun setPreferredPlayerCountLimit(limit: Int) { + checkCommunicationThread() + this.preferredPlayerCount = limit + } + + /** + * Resets the preferred player count limit to 250. + */ + public fun resetPreferredPlayerCountLimit() { + checkCommunicationThread() + this.preferredPlayerCount = DEFAULT_PREFERRED_PLAYER_COUNT + } + + /** + * Sets the resize interval. This is after how many game cycles of inactivity the + * protocol will try to expand out again, to render more players. + * The default value is 10. + * @param interval the interval in game cycles after how long to attempt to increase range. + */ + public fun setResizeInterval(interval: Int) { + checkCommunicationThread() + this.preferredResizeInterval = interval + } + + /** + * Resets the resize interval to the default value of 10. + */ + public fun resetResizeInterval() { + checkCommunicationThread() + this.preferredResizeInterval = DEFAULT_RESIZE_INTERVAL + } + + /** + * Gets the current resize range. This variable might change over time. + */ + public fun getResizeRange(): Int = this.resizeRange + + /** + * Gets the preferred resize range. This value represents the ideal number + * that player info will strive towards. If the value is [Int.MAX_VALUE], + * resizing is disabled and [getResizeRange] is what is used as a constant. + */ + public fun getPreferredResizeRange(): Int = this.preferredResizeRange + + /** + * Sets this avatar as high priority, meaning they will be rendered in large crowds + * if the size of the crowd causes the view range to decrease below the preferred + * range. As an example, a typical preferred range is 15 tiles, but if there's + * a large crowd of people around, it might drop down to say 5 tiles. If there's + * a player that has been marked as high priority 10 tiles away from us, + * they will still render to us if marked as high priority. + */ + public fun setHighPriority() { + this.priority = AvatarPriority.NORMAL + } + + /** + * Sets the avatar as normal priority. This reverses the effects of [setHighPriority]. + */ + public fun setNormalPriority() { + this.priority = AvatarPriority.LOW + } + + /** + * Resizes the view range according to the number of high resolution players currently observed. + * This function will aim to keep the number of high resolution avatars at 250 or less. + * It does so by checking if the number of high resolution avatars is greater than 250 every + * 11 cycle interval. Once the condition is hit, every cycle thereafter, the range will decrement + * by one, until the first cycle where the high resolution count is below the 250 threshold. + * Once it reaches that state, it will remain there for another 11 cycles, before re-validating. + * After those 11 cycles, if the count is less than 250, but our range is below the default of 15, + * it will attempt to start increasing the range. It will continue to increase it by 1 tile every + * cycle until the first cycle during which the high resolution count reaches 250+, or if the range + * reaches the default value. If the high resolution count hits above 250 again, the cycle after that, + * it will decrease the range back by 1 and remain there for the next 11 cycles. + * + * If the [preferredResizeRange] is set to [Int.MAX_VALUE], resizing is halted. + * This is useful in cases such as heat maps, where we need all avatars to be in high resolution + * in order for them to be drawn on the world map. + * @param highResCount the number of avatars in high resolution view. + */ + internal fun resize(highResCount: Int) { + // Resizing is disabled if it is set to max int + if (preferredResizeRange == Int.MAX_VALUE) { + return + } + // If there are more than 250 avatars in high resolution, + // the range decrements by 1 every cycle. + if (highResCount >= preferredPlayerCount) { + if (resizeRange > 0) { + resizeRange-- + } + resizeCounter = 0 + return + } + // If our resize counter gets high enough, the protocol will + // try to increment the range by 1 if it's less than 15 + // otherwise, resets the counter. + if (++resizeCounter >= preferredResizeInterval) { + if (resizeRange < preferredResizeRange) { + resizeRange++ + } else { + resizeCounter = 0 + } + } + } + + override fun toString(): String { + return "PlayerAvatar(" + + "localPlayerIndex=$localPlayerIndex, " + + "preferredResizeRange=$preferredResizeRange, " + + "resizeRange=$resizeRange, " + + "resizeCounter=$resizeCounter, " + + "preferredPlayerCount=$preferredPlayerCount, " + + "preferredResizeInterval=$preferredResizeInterval, " + + "currentCoord=$currentCoord, " + + "priority=$priority, " + + "lastCoord=$lastCoord, " + + "extendedInfo=$extendedInfo, " + + "hidden=$hidden, " + + "allocateCycle=$allocateCycle" + + ")" + } + + private companion object { + /** + * The default range of visibility of other players, in game tiles. + */ + private const val DEFAULT_RESIZE_RANGE = 15 + + /** + * The default interval at which resizing will be checked, in game cycles. + */ + private const val DEFAULT_RESIZE_INTERVAL = 10 + + /** + * The maximum preferred number of players in high resolution. + * Exceeding this count will cause the view range to start lowering. + */ + private const val DEFAULT_PREFERRED_PLAYER_COUNT = 250 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfo.kt new file mode 100644 index 000000000..c66a55c51 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfo.kt @@ -0,0 +1,1954 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.encoder.PlayerExtendedInfoEncoders +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.MoveSpeed +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.ObjTypeCustomisation +import net.rsprot.protocol.internal.game.outgoing.info.precompute +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Tinting +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HeadBar +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.HitMark +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.util.SpotAnim + +public typealias PlayerAvatarExtendedInfoWriter = + AvatarExtendedInfoWriter + +/** + * This data structure keeps track of all the extended info blocks for a given player avatar. + * @param localIndex the index of the avatar who owns this extended info block. + * @param filter the filter responsible for ensuring the total packet size constraint + * is not broken in any way. If this filter does not conform to the contract correctly, + * crashes are likely to happen during encoding. + * @param extendedInfoWriters the list of client-specific writers & encoders of all extended + * info blocks. During caching procedure, all registered client buffers will be built + * concurrently among players. + * @param allocator the byte buffer allocator used to allocate buffers during the caching procedure. + * Any extended info block which is built on-demand is written directly into the main buffer. + * @param huffmanCodec the Huffman codec is used to compress public chat extended info blocks. + */ +public class PlayerAvatarExtendedInfo( + internal var localIndex: Int, + private val filter: ExtendedInfoFilter, + extendedInfoWriters: List, + private val allocator: ByteBufAllocator, + private val huffmanCodec: HuffmanCodecProvider, +) { + /** + * The flags currently enabled for this avatar. + * When an update is requested, the respective flag of that update is appended + * onto this flag. At the end of each cycle, the flag is reset. + * Worth noting, however, that this flag only contains constants within + * the [Companion] of this class. For client-specific encoders, a translation + * occurs to turn these constants into a client-specific flag. + */ + internal var flags: Int = 0 + + /** + * Extended info blocks used to transmit changes to the client, + * wrapped in its own class as we must pass this onto the client-specific + * implementations. + */ + private val blocks: PlayerAvatarExtendedInfoBlocks = PlayerAvatarExtendedInfoBlocks(extendedInfoWriters) + + /** + * The client-specific extended info writers, indexed by the respective [OldSchoolClientType]'s id. + * All clients in use must be registered, or an exception will occur during player info encoding. + */ + private val writers: Array = + buildClientWriterArray(extendedInfoWriters) + + /** + * An int array to track the last cycle during which we recorded other players' appearances. + * If the values align, the client will utilize its previously cached variant. + */ + private val otherAppearanceChangeCycles: IntArray = + IntArray(PlayerInfoProtocol.PROTOCOL_CAPACITY) { + -1 + } + + /** + * The last player info cycle on which our appearance changed. + */ + private var lastAppearanceChangeCycle: Int = 0 + + /** + * A storage of all the observed chat messages that a player saw in a tick. + */ + public val observedChatStorage: ObservedChatStorage = + ObservedChatStorage( + RSProtFlags.captureChat, + RSProtFlags.captureSay, + ) + + /** + * Sets the movement speed for this avatar. This move speed will be used whenever + * the player moves, unless a temporary move speed is utilized, which will take priority. + * The known values are: + * + * ``` + * | Type | Id | + * |------------|----| + * | Stationary | -1 | + * | Crawl | 0 | + * | Walk | 1 | + * | Run | 2 | + * ``` + * @param value the move speed value. + */ + public fun setMoveSpeed(value: Int) { + checkCommunicationThread() + verify { + require(value in -1..2) { + "Unexpected move speed: $value, expected values: -1, 0, 1, 2" + } + } + blocks.moveSpeed.value = value + flags = flags or MOVE_SPEED + } + + /** + * Sets the temporary movement speed for this avatar - this move speed will only + * apply for a single game cycle. + * The known values are: + * ``` + * | Type | Id | + * |-----------------|-----| + * | Stationary | -1 | + * | Crawl | 0 | + * | Walk | 1 | + * | Run | 2 | + * | Teleport | 127 | + * ``` + * @param value the temporary move speed value. + */ + public fun setTempMoveSpeed(value: Int) { + checkCommunicationThread() + verify { + require(value in -1..2 || value == 127) { + "Unexpected temporary move speed: $value, expected values: -1, 0, 1, 2, 127" + } + } + blocks.temporaryMoveSpeed.value = value + flags = flags or TEMP_MOVE_SPEED + } + + /** + * Sets the sequence for this avatar to play. + * @param id the id of the sequence to play, or -1 to stop playing current sequence. + * @param delay the delay in client cycles (20ms/cc) until the avatar starts playing this sequence. + */ + public fun setSequence( + id: Int, + delay: Int, + ) { + checkCommunicationThread() + verify { + require(id == -1 || id in UNSIGNED_SHORT_RANGE) { + "Unexpected sequence id: $id, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + require(delay in UNSIGNED_SHORT_RANGE) { + "Unexpected sequence delay: $delay, expected range: $UNSIGNED_SHORT_RANGE" + } + } + blocks.sequence.id = id.toUShort() + blocks.sequence.delay = delay.toUShort() + flags = flags or SEQUENCE + } + + /** + * Sets the face-locking onto the avatar with index [index]. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * In order to stop facing an entity, set the index value to -1. + * @param index the index of the target to face-lock onto (read above) + */ + public fun setFacePathingEntity(index: Int) { + checkCommunicationThread() + verify { + require(index == -1 || index in 0..0x107FF) { + "Unexpected pathing entity index: $index, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + blocks.facePathingEntity.index = index + flags = flags or FACE_PATHINGENTITY + } + + /** + * Sets the angle for this avatar to face. + * @param angle the angle to face, value range is 0..<2048, + * with 0 implying south, 512 west, 1024 north and 1536 east; interpolate + * between to get finer directions. + */ + public fun setFaceAngle(angle: Int) { + checkCommunicationThread() + verify { + require(angle in 0..2047) { + "Unexpected angle: $angle, expected range: 0-2047" + } + } + blocks.faceAngle.angle = angle.toUShort() + flags = flags or FACE_ANGLE + } + + /** + * Sets the overhead chat of this avatar. + * If the [text] starts with the character `~`, the message will additionally + * also be rendered in the chatbox of everyone nearby, although no chat icons + * will appear alongside. The first `~` character itself will not be rendered + * in that scenario. + * @param text the text to render overhead. + */ + public fun setSay(text: String) { + checkCommunicationThread() + verify { + require(text.length <= 80) { + "Unexpected say input; expected value 80 characters or less, " + + "input len: ${text.length}, input: $text" + } + } + blocks.say.text = text + flags = flags or SAY + } + + /** + * Sets the public chat of this avatar. + * + * Colour table: + * ``` + * | Id | Prefix | Hex Value | + * |-------|-----------|:--------------------------:| + * | 0 | yellow: | 0xFFFF00 | + * | 1 | red: | 0xFF0000 | + * | 2 | green: | 0x00FF00 | + * | 3 | cyan: | 0x00FFFF | + * | 4 | purple: | 0xFF00FF | + * | 5 | white: | 0xFFFFFF | + * | 6 | flash1: | 0xFF0000/0xFFFF00 | + * | 7 | flash2: | 0x0000FF/0x00FFFF | + * | 8 | flash3: | 0x00B000/0x80FF80 | + * | 9 | glow1: | 0xFF0000-0xFFFF00-0x00FFFF | + * | 10 | glow2: | 0xFF0000-0x00FF00-0x0000FF | + * | 11 | glow3: | 0xFFFFFF-0x00FF00-0x00FFFF | + * | 12 | rainbow: | N/A | + * | 13-20 | pattern*: | N/A | + * ``` + * + * Effects table: + * ``` + * | Id | Prefix | + * |----|---------| + * | 1 | wave: | + * | 2 | wave2: | + * | 3 | shake: | + * | 4 | scroll: | + * | 5 | slide: | + * ``` + * + * @param colour the colour id to render (see above) + * @param effects the effects to apply to the text (see above) + * @param modicon the index of the sprite in the modicons group to render before the name + * @param autotyper whether the avatar is using built-in autotyper + * @param text the text to render overhead and in chat + * @param pattern the pattern description if the user is using the pattern colour type + */ + public fun setChat( + colour: Int, + effects: Int, + modicon: Int, + autotyper: Boolean, + text: String, + pattern: ByteArray?, + ) { + checkCommunicationThread() + verify { + require(text.length <= 80) { + "Unexpected chat input; expected value 80 characters or less, " + + "input len: ${text.length}, input: $text" + } + require(colour in 0..20) { + "Unexpected colour value: $colour, expected range: 0-20" + } + // No verification for mod icons, as servers often create custom ranks + } + val patternLength = if (colour in 13..20) colour - 12 else 0 + // Unlike most inputs, these are necessary to avoid crashes, so these can't be turned off. + if (patternLength in 1..8) { + requireNotNull(pattern) { + "Pattern cannot be null if pattern length is defined." + } + require(pattern.size == patternLength) { + "Pattern length does not match the size configured in the colour property." + } + } + blocks.chat.colour = colour.toUByte() + blocks.chat.effects = effects.toUByte() + blocks.chat.modicon = modicon.toUByte() + blocks.chat.autotyper = autotyper + blocks.chat.text = text + blocks.chat.pattern = pattern + flags = flags or CHAT + } + + /** + * Sets an exact movement for this avatar. It should be noted + * that this is done in conjunction with actual movement, as the + * exact move extended info block is only responsible for visualizing + * precise movement, and will synchronize to the real coordinate once + * the exact movement has finished. + * + * @param deltaX1 the coordinate delta between the current absolute + * x coordinate and where the avatar is going. + * @param deltaZ1 the coordinate delta between the current absolute + * z coordinate and where the avatar is going. + * @param delay1 how many client cycles (20ms/cc) until the avatar arrives + * at x/z 1 coordinate. + * @param deltaX2 the coordinate delta between the current absolute + * x coordinate and where the avatar is going. + * @param deltaZ2 the coordinate delta between the current absolute + * z coordinate and where the avatar is going. + * @param delay2 how many client cycles (20ms/cc) until the avatar arrives + * at x/z 2 coordinate. + * @param angle the angle the avatar will be facing throughout the exact movement, + * with 0 implying south, 512 west, 1024 north and 1536 east; interpolate + * between to get finer directions. + */ + public fun setExactMove( + deltaX1: Int, + deltaZ1: Int, + delay1: Int, + deltaX2: Int, + deltaZ2: Int, + delay2: Int, + angle: Int, + ) { + checkCommunicationThread() + verify { + require(delay1 >= 0) { + "First delay cannot be negative: $delay1" + } + require(delay2 >= 0) { + "Second delay cannot be negative: $delay2" + } + require(angle in 0..2047) { + "Unexpected angle value: $angle, expected range: 0..2047" + } + require(deltaX1 in SIGNED_BYTE_RANGE) { + "Unexpected deltaX1: $deltaX1, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaZ1 in SIGNED_BYTE_RANGE) { + "Unexpected deltaZ1: $deltaZ1, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaX2 in SIGNED_BYTE_RANGE) { + "Unexpected deltaX1: $deltaX2, expected range: $SIGNED_BYTE_RANGE" + } + require(deltaZ2 in SIGNED_BYTE_RANGE) { + "Unexpected deltaZ1: $deltaZ2, expected range: $SIGNED_BYTE_RANGE" + } + } + blocks.exactMove.deltaX1 = deltaX1.toUByte() + blocks.exactMove.deltaZ1 = deltaZ1.toUByte() + blocks.exactMove.delay1 = delay1.toUShort() + blocks.exactMove.deltaX2 = deltaX2.toUByte() + blocks.exactMove.deltaZ2 = deltaZ2.toUByte() + blocks.exactMove.delay2 = delay2.toUShort() + blocks.exactMove.direction = angle.toUShort() + flags = flags or EXACT_MOVE + } + + /** + * Sets the spotanim in slot [slot], overriding any previous spotanim + * in that slot in doing so. + * @param slot the slot of the spotanim. + * @param id the id of the spotanim. + * @param delay the delay in client cycles (20ms/cc) until the given spotanim begins rendering. + * @param height the height at which to render the spotanim. + */ + public fun setSpotAnim( + slot: Int, + id: Int, + delay: Int, + height: Int, + ) { + checkCommunicationThread() + verify { + require(slot in 0..= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(selfType in HIT_TYPE_RANGE) { + "Unexpected selfType: $selfType, expected range $HIT_TYPE_RANGE" + } + require(sourceType in HIT_TYPE_RANGE) { + "Unexpected sourceType: $sourceType, expected range $HIT_TYPE_RANGE" + } + require(otherType in HIT_TYPE_RANGE) { + "Unexpected otherType: $otherType, expected range $HIT_TYPE_RANGE" + } + require(value in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected value: $value, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += + HitMark( + sourceIndex, + sourceType.toUShort(), + selfType.toUShort(), + otherType.toUShort(), + value.toUShort(), + delay.toUShort(), + ) + flags = flags or HITS + } + + /** + * Removes the oldest currently showing hitmark on this avatar, + * if one exists. + * @param delay the delay in client cycles (20ms/cc) until the hitmark is removed. + */ + public fun removeHitMark(delay: Int = 0) { + checkCommunicationThread() + if (blocks.hit.hitMarkList.size >= 0xFF) { + return + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += HitMark(0x7FFEu, delay.toUShort()) + flags = flags or HITS + } + + /** + * Adds a simple hitmark on this avatar. + * @param sourceIndex the index of the character that dealt the hit. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for tinting purposes, as both the player who dealt + * the hit, and the recipient will see a tinted variant. + * Everyone else, however, will see a regular darkened hit mark. + * @param selfType the multi hitmark id that supports tinted and darkened variants. + * This is the one that renders to the one who receives the hit, as well as the + * one who dealt it. + * @param otherType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * If the hitmark should only render to the local player, set the [otherType] + * value to -1, forcing it to only render to the recipient (and in the case of + * a [sourceIndex] being defined, the one who dealt the hit) + * @param value the value to show over the hitmark. + * @param selfSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. This one renders to the one who receives + * the hit, as well as the one who dealt the hit. + * @param otherSoakType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * Unlike the [otherType], this does not support -1, as it is not possible to show partial + * soaked hitmarks. + * @param delay the delay in client cycles (20ms/cc) until the hitmark renders. + */ + @JvmOverloads + public fun addSoakedHitMark( + sourceIndex: Int, + selfType: Int, + otherType: Int = selfType, + value: Int, + selfSoakType: Int, + otherSoakType: Int = selfSoakType, + soakValue: Int, + delay: Int = 0, + ) { + addSoakedHitMark( + sourceIndex, + selfType, + selfType, + otherType, + value, + selfSoakType, + selfSoakType, + otherSoakType, + soakValue, + delay, + ) + } + + /** + * Adds a simple hitmark on this avatar. + * @param sourceIndex the index of the character that dealt the hit. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for tinting purposes, as both the player who dealt + * the hit, and the recipient will see a tinted variant. + * Everyone else, however, will see a regular darkened hit mark. + * @param selfType the multi hitmark id that supports tinted and darkened variants. + * This is the one that renders to the one who receives the hit. + * @param sourceSoakType This is the one that renders to the one who dealt the hit, + * defined according to [sourceIndex]. + * @param otherType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * If the hitmark should only render to the local player, set the [otherType] + * value to -1, forcing it to only render to the recipient (and in the case of + * a [sourceIndex] being defined with the respective [sourceType], the one who dealt the hit) + * @param value the value to show over the hitmark. + * @param selfSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. This one renders to the one who receives + * the hit. + * @param sourceSoakType the multi hitmark id that supports tinted and darkened variants, + * shown as soaking next to the normal hitmark. This one renders to the one who dealt + * the hit. + * @param otherSoakType the hitmark id to render to anyone that isn't the recipient, + * or the one who dealt the hit. This will generally be a darkened variant. + * Unlike the [otherType], this does not support -1, as it is not possible to show partial + * soaked hitmarks. + * @param delay the delay in client cycles (20ms/cc) until the hitmark renders. + */ + @JvmOverloads + public fun addSoakedHitMark( + sourceIndex: Int, + selfType: Int, + sourceType: Int, + otherType: Int, + value: Int, + selfSoakType: Int, + sourceSoakType: Int, + otherSoakType: Int, + soakValue: Int, + delay: Int = 0, + ) { + checkCommunicationThread() + if (blocks.hit.hitMarkList.size >= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(selfType in HIT_TYPE_RANGE) { + "Unexpected selfType: $selfType, expected range $HIT_TYPE_RANGE" + } + require(sourceType in HIT_TYPE_RANGE) { + "Unexpected sourceType: $sourceType, expected range $HIT_TYPE_RANGE" + } + require(otherType in HIT_TYPE_RANGE) { + "Unexpected otherType: $otherType, expected range $HIT_TYPE_RANGE" + } + require(value in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected value: $value, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(selfSoakType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected selfSoakType: $selfSoakType, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(sourceSoakType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected sourceSoakType: $sourceSoakType, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(otherSoakType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected otherSoakType: $otherSoakType, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(soakValue in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected soakValue: $soakValue, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(delay in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected delay: $delay, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.hitMarkList += + HitMark( + sourceIndex, + sourceType.toUShort(), + selfType.toUShort(), + otherType.toUShort(), + value.toUShort(), + sourceSoakType.toUShort(), + selfSoakType.toUShort(), + otherSoakType.toUShort(), + soakValue.toUShort(), + delay.toUShort(), + ) + flags = flags or HITS + } + + /** + * Adds a headbar onto the avatar. + * If a headbar by the same id already exists, updates the status of the old one. + * Up to four distinct headbars can be rendered simultaneously. + * + * @param sourceIndex the index of the entity that dealt the hit that resulted in this headbar. + * If the target avatar is a player, add 0x10000 to the real index value (0-2048). + * If the target avatar is a NPC, set the index as it is. + * If there is no source, set the index to -1. + * The index will be used for rendering purposes, as both the player who dealt + * the hit, and the recipient will see the [selfType] variant, and everyone else + * will see the [otherType] variant, which, if set to -1 will be skipped altogether. + * @param selfType the id of the headbar to render to the entity on which the headbar appears, + * as well as the source who resulted in the creation of the headbar. + * @param otherType the id of the headbar to render to everyone that doesn't fit the [selfType] + * criteria. If set to -1, the headbar will not be rendered to these individuals. + * @param startFill the number of pixels to render of this headbar at in the start. + * The number of pixels that a headbar supports is defined in its respective headbar config. + * @param endFill the number of pixels to render of this headbar at in the end, + * if a [startTime] and [endTime] are defined. + * @param startTime the delay in client cycles (20ms/cc) until the headbar renders at [startFill] + * @param endTime the delay in client cycles (20ms/cc) until the headbar arrives at [endFill]. + */ + @JvmOverloads + public fun addHeadBar( + sourceIndex: Int, + selfType: Int, + otherType: Int = selfType, + startFill: Int, + endFill: Int = startFill, + startTime: Int = 0, + endTime: Int = 0, + ) { + checkCommunicationThread() + if (blocks.hit.headBarList.size >= 0xFF) { + return + } + verify { + // Index being incorrect would not lead to a crash + require(sourceIndex == -1 || sourceIndex in 0..0x107FF) { + "Unexpected source index: $sourceIndex, expected values: -1 to reset, " + + "0-65535 for NPCs, 65536-67583 for players" + } + // Fills are transmitted via a byte so they would not crash + require(startFill in UNSIGNED_BYTE_RANGE) { + "Unexpected startFill: $startFill, expected range $UNSIGNED_BYTE_RANGE" + } + require(endFill in UNSIGNED_BYTE_RANGE) { + "Unexpected endFill: $endFill, expected range $UNSIGNED_BYTE_RANGE" + } + } + + // All the properties below here would result in a crash if an invalid input was provided. + require(selfType == -1 || selfType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected id: $selfType, expected value -1 or in range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(otherType == -1 || otherType in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected id: $otherType, expected value -1 or in range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(startTime in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected startTime: $startTime, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + require(endTime in UNSIGNED_SMART_1_OR_2_RANGE) { + "Unexpected endTime: $endTime, expected range $UNSIGNED_SMART_1_OR_2_RANGE" + } + blocks.hit.headBarList += + HeadBar( + sourceIndex, + selfType.toUShort(), + otherType.toUShort(), + startFill.toUByte(), + endFill.toUByte(), + startTime.toUShort(), + endTime.toUShort(), + ) + flags = flags or HITS + } + + /** + * Removes a headbar on this avatar by the id of [id], if one renders. + * @param id the id of the head bar to remove. + */ + public fun removeHeadBar(id: Int) { + addHeadBar( + -1, + id, + startFill = 0, + endTime = HeadBar.REMOVED.toInt(), + ) + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + */ + @Deprecated( + message = "Deprecated. Use setTinting(startTime, endTime, hue, saturation, lightness, weight) for consistency.", + replaceWith = ReplaceWith("setTinting(startTime, endTime, hue, saturation, lightness, weight)"), + ) + public fun tinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + ) { + setTinting(startTime, endTime, hue, saturation, lightness, weight) + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + */ + public fun setTinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + ) { + checkCommunicationThread() + verify { + require(startTime in UNSIGNED_SHORT_RANGE) { + "Unexpected startTime: $startTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime in UNSIGNED_SHORT_RANGE) { + "Unexpected endTime: $endTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime >= startTime) { + "End time should be equal to or greater than start time: $endTime > $startTime" + } + require(hue in UNSIGNED_BYTE_RANGE) { + "Unexpected hue: $hue, expected range $UNSIGNED_BYTE_RANGE" + } + require(saturation in UNSIGNED_BYTE_RANGE) { + "Unexpected saturation: $saturation, expected range $UNSIGNED_BYTE_RANGE" + } + require(lightness in UNSIGNED_BYTE_RANGE) { + "Unexpected lightness: $lightness, expected range $UNSIGNED_BYTE_RANGE" + } + require(weight in UNSIGNED_BYTE_RANGE) { + "Unexpected weight: $weight, expected range $UNSIGNED_BYTE_RANGE" + } + } + val tint = blocks.tinting.global + tint.start = startTime.toUShort() + tint.end = endTime.toUShort() + tint.hue = hue.toUByte() + tint.saturation = saturation.toUByte() + tint.lightness = lightness.toUByte() + tint.weight = weight.toUByte() + flags = flags or TINTING + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + * @param visibleTo the player who will see the tint applied. + * Note that this only accepts player indices, and not NPC ones like many other extended info blocks. + */ + @Deprecated( + message = + "Deprecated. Use setSpecificTinting(startTime, endTime, hue, saturation, " + + "lightness, weight, visibleTo) for consistency.", + replaceWith = + ReplaceWith( + "setSpecificTinting(startTime, endTime, hue, saturation, " + + "lightness, weight, visibleTo)", + ), + ) + public fun specificTinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + visibleTo: PlayerInfo, + ) { + setSpecificTinting(startTime, endTime, hue, saturation, lightness, weight, visibleTo) + } + + /** + * Applies a tint over the non-textured parts of the character. + * @param startTime the delay in client cycles (20ms/cc) until the tinting is applied. + * @param endTime the timestamp in client cycles (20ms/cc) until the tinting finishes. + * @param hue the hue of the tint. + * @param saturation the saturation of the tint. + * @param lightness the lightness of the tint. + * @param weight the weight (or opacity) of the tint. + * @param visibleTo the player who will see the tint applied. + * Note that this only accepts player indices, and not NPC ones like many other extended info blocks. + */ + public fun setSpecificTinting( + startTime: Int, + endTime: Int, + hue: Int, + saturation: Int, + lightness: Int, + weight: Int, + visibleTo: PlayerInfo, + ) { + checkCommunicationThread() + verify { + require(startTime in UNSIGNED_SHORT_RANGE) { + "Unexpected startTime: $startTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime in UNSIGNED_SHORT_RANGE) { + "Unexpected endTime: $endTime, expected range $UNSIGNED_SHORT_RANGE" + } + require(endTime >= startTime) { + "End time should be equal to or greater than start time: $endTime > $startTime" + } + require(hue in UNSIGNED_BYTE_RANGE) { + "Unexpected hue: $hue, expected range $UNSIGNED_BYTE_RANGE" + } + require(saturation in UNSIGNED_BYTE_RANGE) { + "Unexpected saturation: $saturation, expected range $UNSIGNED_BYTE_RANGE" + } + require(lightness in UNSIGNED_BYTE_RANGE) { + "Unexpected lightness: $lightness, expected range $UNSIGNED_BYTE_RANGE" + } + require(weight in UNSIGNED_BYTE_RANGE) { + "Unexpected weight: $weight, expected range $UNSIGNED_BYTE_RANGE" + } + } + val tint = + Tinting() + blocks.tinting.observerDependent[visibleTo.avatar.extendedInfo.localIndex] = tint + tint.start = startTime.toUShort() + tint.end = endTime.toUShort() + tint.hue = hue.toUByte() + tint.saturation = saturation.toUByte() + tint.lightness = lightness.toUByte() + tint.weight = weight.toUByte() + visibleTo.observerExtendedInfoFlags.addFlag( + localIndex, + TINTING, + ) + } + + /** + * Sets the name of the avatar. + * @param name the name to assign. + */ + public fun setName(name: String) { + checkCommunicationThread() + if (blocks.appearance.name == name) { + return + } + blocks.appearance.name = name + flagAppearance() + } + + /** + * Sets the combat level of the avatar. + * @param combatLevel the level to assign. + */ + public fun setCombatLevel(combatLevel: Int) { + checkCommunicationThread() + verify { + require(combatLevel in UNSIGNED_BYTE_RANGE) { + "Unexpected combatLevel $combatLevel, expected range $UNSIGNED_BYTE_RANGE" + } + } + val level = combatLevel.toUByte() + if (blocks.appearance.combatLevel == level) { + return + } + blocks.appearance.combatLevel = level + flagAppearance() + } + + /** + * Sets the skill level of the avatar, seen when right-clicking players as "skill: value", + * instead of the usual combat level. Set to 0 to render combat level instead. + * @param skillLevel the level to render + */ + public fun setSkillLevel(skillLevel: Int) { + checkCommunicationThread() + verify { + require(skillLevel in UNSIGNED_SHORT_RANGE) { + "Unexpected skill level $skillLevel, expected range $UNSIGNED_SHORT_RANGE" + } + } + val level = skillLevel.toUShort() + if (blocks.appearance.skillLevel == level) { + return + } + blocks.appearance.skillLevel = level + flagAppearance() + } + + /** + * Sets this avatar hidden (or un-hidden) client-sided. + * If the observer is a J-Mod or above, the character will render regardless. + * It is worth noting that plugin clients such as RuneLite will render information + * about these avatars regardless of their hidden status. + * @param hidden whether to hide the avatar. + */ + public fun setHidden(hidden: Boolean) { + checkCommunicationThread() + if (blocks.appearance.hidden == hidden) { + return + } + blocks.appearance.hidden = hidden + flagAppearance() + } + + /** + * Sets the character male or female. + * @param isMale whether to set the character male (or female, if false) + */ + @Deprecated( + message = "Deprecated. Use setBodyType(type) for consistency.", + replaceWith = ReplaceWith("setBodyType(type)"), + ) + public fun setMale(isMale: Boolean) { + checkCommunicationThread() + setBodyType(if (isMale) 0 else 1) + } + + /** + * Sets the body type of the character. + * @param type the body type of the character. + */ + public fun setBodyType(type: Int) { + checkCommunicationThread() + if (blocks.appearance.bodyType == type.toUByte()) { + return + } + blocks.appearance.bodyType = type.toUByte() + flagAppearance() + } + + /** + * Sets the pronoun of this avatar. + * @param num the number to set, with the value 0 being male, 1 being female, + * and 2 being 'other'. + */ + @Deprecated( + message = "Deprecated. Use setPronoun(num) for consistency.", + replaceWith = ReplaceWith("setPronoun(num)"), + ) + public fun setTextGender(num: Int) { + setPronoun(num) + } + + /** + * Sets the pronoun of this avatar. + * @param num the number to set, with the value 0 being male, 1 being female, + * and 2 being 'other'. + */ + public fun setPronoun(num: Int) { + checkCommunicationThread() + verify { + require(num in UNSIGNED_BYTE_RANGE) { + "Unexpected textGender $num, expected range $UNSIGNED_BYTE_RANGE" + } + } + val pronoun = num.toUByte() + if (blocks.appearance.pronoun == pronoun) { + return + } + blocks.appearance.pronoun = pronoun + flagAppearance() + } + + /** + * Sets the skull icon over this avatar. + * @param icon the id of the icon to render, or -1 to not show any. + */ + public fun setSkullIcon(icon: Int) { + checkCommunicationThread() + verify { + require(icon == -1 || icon in UNSIGNED_BYTE_RANGE) { + "Unexpected skullIcon $icon, expected value -1 or in range $UNSIGNED_BYTE_RANGE" + } + } + val skullIcon = icon.toUByte() + if (blocks.appearance.skullIcon == skullIcon) { + return + } + blocks.appearance.skullIcon = skullIcon + flagAppearance() + } + + /** + * Sets the overhead icon over this avatar (e.g. prayer icons) + * @param icon the id of the icon to render, or -1 to not show any. + */ + public fun setOverheadIcon(icon: Int) { + checkCommunicationThread() + verify { + require(icon == -1 || icon in UNSIGNED_BYTE_RANGE) { + "Unexpected overheadIcon $icon, expected value -1 or in range $UNSIGNED_BYTE_RANGE" + } + } + val overheadIcon = icon.toUByte() + if (blocks.appearance.overheadIcon == overheadIcon) { + return + } + blocks.appearance.overheadIcon = overheadIcon + flagAppearance() + } + + /** + * Transforms this avatar to the respective NPC, or back to player if the [id] is -1. + * @param id the id of the NPC to transform to, or -1 if resetting. + */ + @Deprecated( + message = "Deprecated. Use setTransmogrification(id) for consistency.", + replaceWith = ReplaceWith("setTransmogrification(id)"), + ) + public fun transformToNpc(id: Int) { + setTransmogrification(id) + } + + /** + * Transforms this avatar to the respective NPC, or back to player if the [id] is -1. + * @param id the id of the NPC to transform to, or -1 if resetting. + */ + public fun setTransmogrification(id: Int) { + checkCommunicationThread() + verify { + require(id == -1 || id in UNSIGNED_SHORT_RANGE) { + "Unexpected id $id, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + } + val npcId = id.toUShort() + if (blocks.appearance.transformedNpcId == npcId) { + return + } + blocks.appearance.transformedNpcId = npcId + flagAppearance() + } + + /** + * Sets an ident kit. Note that this function does not rely on wearpos values, + * as those range from 0 to 11. Ident kit values only range from 0 to 6, which would + * result in some wasted memory. + * A list of wearpos to ident kit can also be found in + * [net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Appearance.identKitSlotList] + * + * Ident kit table: + * ```kt + * | Id | Slot | + * |:--:|:------:| + * | 0 | Hair | + * | 1 | Beard | + * | 2 | Body | + * | 3 | Arms | + * | 4 | Gloves | + * | 5 | Legs | + * | 6 | Boots | + * ``` + * + * @param identKitSlot the position in which to set this ident kit. + * @param value the value of the ident kit config, or -1 if hidden. + */ + public fun setIdentKit( + identKitSlot: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(identKitSlot in 0..6) { + "Unexpected wearPos $identKitSlot, expected range 0..6" + } + require(value == -1 || value in IDENT_KIT_RANGE) { + "Unexpected value $value, expected value -1 or in range $IDENT_KIT_RANGE" + } + } + val valueAsShort = value.toShort() + val cur = blocks.appearance.identKit[identKitSlot] + if (cur == valueAsShort) { + return + } + blocks.appearance.identKit[identKitSlot] = valueAsShort + flagAppearance() + } + + /** + * Sets a worn object in the given [wearpos]. + * @param wearpos the main wearpos in which the obj equips. + * @param id the obj id to set in that wearpos, or -1 to not have anything. + * @param wearpos2 the secondary wearpos that this obj utilizes, hiding whatever + * ident kit was in that specific wearpos (e.g. hair, beard), or -1 to not use any. + * @param wearpos3 the tertiary wearpos that this obj utilizes, hiding whatever + * ident kit was in that specific wearpos (e.g. hair, beard), or -1 to not use any. + */ + public fun setWornObj( + wearpos: Int, + id: Int, + wearpos2: Int, + wearpos3: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearPos $wearpos, expected range 0..11" + } + require(id == -1 || id in OBJ_RANGE) { + "Unexpected id $id, expected value -1 or range $OBJ_RANGE" + } + require(wearpos2 == -1 || wearpos2 in 0..11) { + "Unexpected wearpos2 $wearpos2, expected value -1 or in range 0..11" + } + require(wearpos3 == -1 || wearpos3 in 0..11) { + "Unexpected wearpos3 $wearpos3, expected value -1 or in range 0..11" + } + } + val valueAsShort = id.toShort() + val cur = blocks.appearance.wornObjs[wearpos] + if (cur == valueAsShort) { + return + } + blocks.appearance.wornObjs[wearpos] = valueAsShort + val hiddenSlotsBitpacked = (wearpos2 and 0xF shl 4) or (wearpos3 and 0xF) + blocks.appearance.hiddenWearPos[wearpos] = hiddenSlotsBitpacked.toByte() + flagAppearance() + } + + /** + * Sets the colour of this avatar's appearance. + * @param slot the slot of the element to colour + * @param value the 16-bit HSL colour value + */ + public fun setColour( + slot: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(slot in 0..<5) { + "Unexpected slot $slot, expected range 0..<5" + } + require(value in UNSIGNED_BYTE_RANGE) { + "Unexpected value $value, expected range $UNSIGNED_BYTE_RANGE" + } + } + val valueAsByte = value.toByte() + val cur = blocks.appearance.colours[slot] + if (cur == valueAsByte) { + return + } + blocks.appearance.colours[slot] = valueAsByte + flagAppearance() + } + + /** + * Sets the base animations of this avatar. + * @param readyAnim the animation used when the avatar is standing still. + * @param turnAnim the animation used when the avatar is turning on-spot without movement. + * @param walkAnim the animation used when the avatar is walking forward. + * @param walkAnimBack the animation used when the avatar is walking backwards. + * @param walkAnimLeft the animation used when the avatar is walking to the left. + * @param walkAnimRight the animation used when the avatar is walking to the right. + * @param runAnim the animation used when the avatar is running. + */ + public fun setBaseAnimationSet( + readyAnim: Int, + turnAnim: Int, + walkAnim: Int, + walkAnimBack: Int, + walkAnimLeft: Int, + walkAnimRight: Int, + runAnim: Int, + ) { + checkCommunicationThread() + verify { + require(readyAnim == -1 || readyAnim in UNSIGNED_SHORT_RANGE) { + "Unexpected readyAnim $readyAnim, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(turnAnim == -1 || turnAnim in UNSIGNED_SHORT_RANGE) { + "Unexpected turnAnim $turnAnim, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(walkAnim == -1 || walkAnim in UNSIGNED_SHORT_RANGE) { + "Unexpected walkAnim $walkAnim, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(walkAnimBack == -1 || walkAnimBack in UNSIGNED_SHORT_RANGE) { + "Unexpected walkAnimBack $walkAnimBack, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(walkAnimLeft == -1 || walkAnimLeft in UNSIGNED_SHORT_RANGE) { + "Unexpected walkAnimLeft $walkAnimLeft, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(walkAnimRight == -1 || walkAnimRight in UNSIGNED_SHORT_RANGE) { + "Unexpected walkAnimRight $walkAnimRight, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + require(runAnim == -1 || runAnim in UNSIGNED_SHORT_RANGE) { + "Unexpected runAnim $runAnim, expected value -1 or range $UNSIGNED_SHORT_RANGE" + } + } + blocks.appearance.readyAnim = readyAnim.toUShort() + blocks.appearance.turnAnim = turnAnim.toUShort() + blocks.appearance.walkAnim = walkAnim.toUShort() + blocks.appearance.walkAnimBack = walkAnimBack.toUShort() + blocks.appearance.walkAnimLeft = walkAnimLeft.toUShort() + blocks.appearance.walkAnimRight = walkAnimRight.toUShort() + blocks.appearance.runAnim = runAnim.toUShort() + flagAppearance() + } + + /** + * Sets the name extras of this avatar, rendered when right-clicking users. + * @param beforeName the text to render before this avatar's name. + * @param afterName the text to render after this avatar's name, but before the combat level. + * @param afterCombatLevel the text to render after this avatar's combat level. + */ + @Deprecated( + message = "Deprecated. Use setNameExtras(beforeName, afterName, afterCombatLevel) for consistency.", + replaceWith = ReplaceWith("setNameExtras(beforeName, afterName, afterCombatLevel)"), + ) + public fun nameExtras( + beforeName: String, + afterName: String, + afterCombatLevel: String, + ) { + setNameExtras(beforeName, afterName, afterCombatLevel) + } + + /** + * Sets the name extras of this avatar, rendered when right-clicking users. + * @param beforeName the text to render before this avatar's name. + * @param afterName the text to render after this avatar's name, but before the combat level. + * @param afterCombatLevel the text to render after this avatar's combat level. + */ + public fun setNameExtras( + beforeName: String, + afterName: String, + afterCombatLevel: String, + ) { + checkCommunicationThread() + verify { + require(beforeName.length in UNSIGNED_BYTE_RANGE) { + "Unexpected beforeName length ${beforeName.length}, expected range $UNSIGNED_BYTE_RANGE" + } + require(afterName.length in UNSIGNED_BYTE_RANGE) { + "Unexpected afterName length ${afterName.length}, expected range $UNSIGNED_BYTE_RANGE" + } + require(afterCombatLevel.length in UNSIGNED_BYTE_RANGE) { + "Unexpected afterCombatLevel length ${afterCombatLevel.length}, expected range $UNSIGNED_BYTE_RANGE" + } + } + blocks.appearance.beforeName = beforeName + blocks.appearance.afterName = afterName + blocks.appearance.afterCombatLevel = afterCombatLevel + flagAppearance() + } + + /** + * Forces a model refresh client-side even if the worn objects + base colour + gender have not changed. + * This is particularly important to enable when setting or clearing any obj type customisations, + * as those are not considered when calculating the hash code. + */ + @Deprecated( + message = "Deprecated. Use setForceModelRefresh(enabled) for consistency.", + replaceWith = ReplaceWith("setForceModelRefresh(enabled)"), + ) + public fun forceModelRefresh(enabled: Boolean) { + setForceModelRefresh(enabled) + } + + /** + * Forces a model refresh client-side even if the worn objects + base colour + gender have not changed. + * This is particularly important to enable when setting or clearing any obj type customisations, + * as those are not considered when calculating the hash code. + */ + public fun setForceModelRefresh(enabled: Boolean) { + checkCommunicationThread() + blocks.appearance.forceModelRefresh = enabled + } + + /** + * Clears any obj type customisations applied to [wearpos]. + * @param wearpos the worn item slot. + */ + @Deprecated( + message = "Deprecated. Use resetObjTypeCustomisation(wearpos) for consistency.", + replaceWith = ReplaceWith("resetObjTypeCustomisation(wearpos)"), + ) + public fun clearObjTypeCustomisation(wearpos: Int) { + resetObjTypeCustomisation(wearpos) + } + + /** + * Clears any obj type customisations applied to [wearpos]. + * @param wearpos the worn item slot. + */ + public fun resetObjTypeCustomisation(wearpos: Int) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + } + if (blocks.appearance.objTypeCustomisation[wearpos] == null) { + return + } + blocks.appearance.objTypeCustomisation[wearpos] = null + flagAppearance() + } + + /** + * Allocates an obj type customisation in [wearpos] if it doesn't already exist. + * @param wearpos the wearpos in which a customisation is being made. + * @return the customisation class holding the state overrides of this obj. + */ + private fun allocObjCustomisation(wearpos: Int): ObjTypeCustomisation { + var customisation = blocks.appearance.objTypeCustomisation[wearpos] + if (customisation == null) { + customisation = ObjTypeCustomisation() + blocks.appearance.objTypeCustomisation[wearpos] = customisation + } + return customisation + } + + /** + * Recolours part of an obj in the first slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the colour to override. + * @param value the 16 bit HSL colour to override with. + */ + @Deprecated( + message = "Deprecated. Use setObjRecol1(wearpos, index, value) for consistency.", + replaceWith = ReplaceWith("setObjRecol1(wearpos, index, value)"), + ) + public fun objRecol1( + wearpos: Int, + index: Int, + value: Int, + ) { + setObjRecol1(wearpos, index, value) + } + + /** + * Recolours part of an obj in the first slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the colour to override. + * @param value the 16 bit HSL colour to override with. + */ + public fun setObjRecol1( + wearpos: Int, + index: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(index in 0..14) { + "Unexpected recol index $index, expected range 0..14" + } + require(value in UNSIGNED_SHORT_RANGE) { + "Unexpected value $value, expected range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.recolIndices = ((customisation.recolIndices.toInt() and 0xF0) or (index and 0xF)).toUByte() + customisation.recol1 = value.toUShort() + flagAppearance() + } + + /** + * Recolours part of an obj in the second slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the colour to override. + * @param value the 16 bit HSL colour to override with. + */ + @Deprecated( + message = "Deprecated. Use setObjRecol2(wearpos, index, value) for consistency.", + replaceWith = ReplaceWith("setObjRecol2(wearpos, index, value)"), + ) + public fun objRecol2( + wearpos: Int, + index: Int, + value: Int, + ) { + setObjRecol2(wearpos, index, value) + } + + /** + * Recolours part of an obj in the second slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the colour to override. + * @param value the 16 bit HSL colour to override with. + */ + public fun setObjRecol2( + wearpos: Int, + index: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(index in 0..14) { + "Unexpected recol index $index, expected range 0..14" + } + require(value in UNSIGNED_SHORT_RANGE) { + "Unexpected value $value, expected range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.recolIndices = ((customisation.recolIndices.toInt() and 0xF) or ((index and 0xF) shl 4)).toUByte() + customisation.recol2 = value.toUShort() + flagAppearance() + } + + /** + * Retextures part of an obj in the first slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the texture to override. + * @param value the id of the texture to override with. + */ + @Deprecated( + message = "Deprecated. Use setObjRetex1(wearpos, index, value) for consistency.", + replaceWith = ReplaceWith("setObjRetex1(wearpos, index, value)"), + ) + public fun objRetex1( + wearpos: Int, + index: Int, + value: Int, + ) { + setObjRetex1(wearpos, index, value) + } + + /** + * Retextures part of an obj in the first slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the texture to override. + * @param value the id of the texture to override with. + */ + public fun setObjRetex1( + wearpos: Int, + index: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(index in 0..14) { + "Unexpected retex index $index, expected range 0..14" + } + require(value in UNSIGNED_SHORT_RANGE) { + "Unexpected value $value, expected range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.retexIndices = ((customisation.retexIndices.toInt() and 0xF0) or (index and 0xF)).toUByte() + customisation.retex1 = value.toUShort() + flagAppearance() + } + + /** + * Retextures part of an obj in the second slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the texture to override. + * @param value the id of the texture to override with. + */ + @Deprecated( + message = "Deprecated. Use setObjRetex2(wearpos, index, value) for consistency.", + replaceWith = ReplaceWith("setObjRetex2(wearpos, index, value)"), + ) + public fun objRetex2( + wearpos: Int, + index: Int, + value: Int, + ) { + setObjRetex2(wearpos, index, value) + } + + /** + * Retextures part of an obj in the second slot (out of two). + * @param wearpos the position in which the obj is worn. + * @param index the source index of the texture to override. + * @param value the id of the texture to override with. + */ + public fun setObjRetex2( + wearpos: Int, + index: Int, + value: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(index in 0..14) { + "Unexpected retex index $index, expected range 0..14" + } + require(value in UNSIGNED_SHORT_RANGE) { + "Unexpected value $value, expected range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.retexIndices = ((customisation.retexIndices.toInt() and 0xF) or ((index and 0xF) shl 4)).toUByte() + customisation.retex2 = value.toUShort() + flagAppearance() + } + + /** + * Sets the worn models of an obj at wearpos [wearpos]. + * @param manWear the male wear model to use + * @param womanWear the female wear model to use + */ + public fun setObjWearModels( + wearpos: Int, + manWear: Int, + womanWear: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(manWear == -1 || manWear in UNSIGNED_SHORT_RANGE) { + "Invalid man wear model $manWear, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + require(womanWear == -1 || womanWear in UNSIGNED_SHORT_RANGE) { + "Invalid man wear model $womanWear, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.manWear = manWear.toUShort() + customisation.womanWear = womanWear.toUShort() + flagAppearance() + } + + /** + * Sets the head models of an obj at wearpos [wearpos]. + * @param manHead the male head model to use + * @param womanHead the female head model to use + */ + public fun setObjHeadModels( + wearpos: Int, + manHead: Int, + womanHead: Int, + ) { + checkCommunicationThread() + verify { + require(wearpos in 0..11) { + "Unexpected wearpos $wearpos, expected range 0..11" + } + require(manHead == -1 || manHead in UNSIGNED_SHORT_RANGE) { + "Invalid man wear model $manHead, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + require(womanHead == -1 || womanHead in UNSIGNED_SHORT_RANGE) { + "Invalid man wear model $womanHead, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + } + val customisation = allocObjCustomisation(wearpos) + customisation.manHead = manHead.toUShort() + customisation.womanHead = womanHead.toUShort() + flagAppearance() + } + + /** + * Flags appearance to have changed, in order for it to be synchronized to all observers. + */ + private fun flagAppearance() { + flags = flags or APPEARANCE + lastAppearanceChangeCycle = PlayerInfoProtocol.cycleCount + } + + /** + * Clears any transient extended info blocks which only applied for this cycle, + * making it ready for the next. + */ + internal fun postUpdate() { + clearTransientExtendedInformation() + flags = 0 + } + + /** + * Resets all the properties of this extended info object, making it ready for use + * by another avatar. + */ + internal fun reset() { + flags = 0 + this.lastAppearanceChangeCycle = 0 + this.otherAppearanceChangeCycles.fill(-1) + blocks.appearance.clear() + blocks.moveSpeed.clear() + blocks.temporaryMoveSpeed.clear() + blocks.sequence.clear() + blocks.facePathingEntity.clear() + blocks.faceAngle.clear() + blocks.say.clear() + blocks.chat.clear() + blocks.exactMove.clear() + blocks.spotAnims.clear() + blocks.hit.clear() + blocks.tinting.clear() + observedChatStorage.reset() + } + + /** + * Resets the cached state on reconnect, ensuring we inform the client of all that was + * previously assigned. + */ + internal fun onReconnect() { + this.lastAppearanceChangeCycle = 0 + this.otherAppearanceChangeCycles.fill(-1) + } + + /** + * Gets all the extended info flags which must be updated for the given [observer], + * based on what is out of date with what they last saw (if they saw the player before). + * @param observer the avatar observing us. + * @return the flags that need updating. + */ + internal fun getLowToHighResChangeExtendedInfoFlags( + observer: PlayerAvatarExtendedInfo, + oldSchoolClientType: OldSchoolClientType, + ): Int { + val lastObservation = observer.otherAppearanceChangeCycles[localIndex] + var flag = 0 + if (this.flags and APPEARANCE == 0 && + lastObservation < lastAppearanceChangeCycle && + blocks.appearance.isPrecomputed(oldSchoolClientType) + ) { + flag = flag or APPEARANCE + } + if (this.flags and MOVE_SPEED == 0 && + (blocks.moveSpeed.value != MoveSpeed.DEFAULT_MOVESPEED || lastObservation != -1) && + blocks.moveSpeed.isPrecomputed(oldSchoolClientType) + ) { + flag = flag or MOVE_SPEED + } + if (this.flags and FACE_PATHINGENTITY == 0 && + (blocks.facePathingEntity.index != FacePathingEntity.DEFAULT_VALUE || lastObservation != -1) && + blocks.facePathingEntity.isPrecomputed(oldSchoolClientType) + ) { + flag = flag or FACE_PATHINGENTITY + } + if (this.flags and FACE_ANGLE == 0 && + (blocks.faceAngle.angle != FaceAngle.DEFAULT_VALUE || lastObservation != -1) && + blocks.faceAngle.isPrecomputed(oldSchoolClientType) + ) { + flag = flag or FACE_ANGLE + } + return flag + } + + /** + * Silently synchronizes the angle of the avatar, meaning any new observers will see them + * at this specific angle. + * @param angle the angle to render them under. + */ + public fun syncAngle(angle: Int) { + checkCommunicationThread() + this.blocks.faceAngle.syncAngle(angle) + } + + /** + * Pre-computes all the buffers for this avatar. + * Pre-computation is done, so we don't have to calculate these extended info blocks + * for every avatar that observes us. Instead, we can do more performance-efficient + * operations of native memory copying to get the latest extended info blocks. + */ + internal fun precompute() { + // Hits and tinting do not get precomputed + if (flags and APPEARANCE != 0) { + blocks.appearance.precompute(allocator, huffmanCodec) + } + if (flags and TEMP_MOVE_SPEED != 0) { + blocks.temporaryMoveSpeed.precompute(allocator, huffmanCodec) + } + if (flags and SEQUENCE != 0) { + blocks.sequence.precompute(allocator, huffmanCodec) + } + if (flags and FACE_ANGLE != 0 || blocks.faceAngle.outOfDate) { + blocks.faceAngle.markUpToDate() + blocks.faceAngle.precompute(allocator, huffmanCodec) + } + if (flags and SAY != 0) { + blocks.say.precompute(allocator, huffmanCodec) + } + if (flags and CHAT != 0) { + blocks.chat.precompute(allocator, huffmanCodec) + } + if (flags and EXACT_MOVE != 0) { + blocks.exactMove.precompute(allocator, huffmanCodec) + } + if (flags and SPOTANIM != 0) { + blocks.spotAnims.precompute(allocator, huffmanCodec) + } + if (flags and FACE_PATHINGENTITY != 0) { + blocks.facePathingEntity.precompute(allocator, huffmanCodec) + } + if (flags and MOVE_SPEED != 0) { + blocks.moveSpeed.precompute(allocator, huffmanCodec) + } + } + + /** + * Writes the extended info block of this avatar for the given observer. + * @param oldSchoolClientType the client that the observer is using. + * @param buffer the buffer into which the extended info block should be written. + * @param observerFlag the dynamic out-of-date flags that we must send to the observer + * on-top of everything that was pre-computed earlier. + * @param observer the avatar that is observing us. + * @param remainingAvatars the number of avatars that must still be updated for + * the given [observer], necessary to avoid memory overflow. + */ + internal fun pExtendedInfo( + oldSchoolClientType: OldSchoolClientType, + buffer: JagByteBuf, + observerFlag: Int, + observer: PlayerAvatarExtendedInfo, + remainingAvatars: Int, + newlyAdded: Boolean, + ): Boolean { + var flag = this.flags or observerFlag + // If the player was just added this cycle, exclude the SAY extended info + // as the client will crash in revision 226 - this is because say comes before + // Appearance, and it assumes the player's name is not null, which it will be if new + if (newlyAdded) { + flag = flag and SAY.inv() + } + if (!filter.accept( + buffer.writableBytes(), + flag, + remainingAvatars, + observer.otherAppearanceChangeCycles[localIndex] != -1, + ) + ) { + buffer.p1(0) + return false + } + val writer = + requireNotNull(writers[oldSchoolClientType.id]) { + "Extended info writer missing for client $oldSchoolClientType" + } + + // If appearance is flagged, ensure we synchronize the changes counter + if (flag and APPEARANCE != 0) { + observer.otherAppearanceChangeCycles[localIndex] = lastAppearanceChangeCycle + } + // Note: The order must be as client expects it, in 236 chat is before say + if (flag and CHAT != 0) { + observer.observedChatStorage.trackChat(this.localIndex, this.blocks.chat) + } + if (flag and SAY != 0) { + val appendToChatbox = + this.blocks.say.text + ?.get(0) == '~' + if (localIndex == observer.localIndex || appendToChatbox) { + observer.observedChatStorage.trackSay(this.localIndex, this.blocks.say) + } + } + writer.pExtendedInfo( + buffer, + localIndex, + observer.localIndex, + flag, + blocks, + flagWriteIndex = -1, + ) + return true + } + + /** + * Clears any flagged transient extended information blocks from this cycle. + */ + private fun clearTransientExtendedInformation() { + if (flags and TEMP_MOVE_SPEED != 0) { + blocks.temporaryMoveSpeed.clear() + } + if (flags and SEQUENCE != 0) { + blocks.sequence.clear() + } + if (flags and SAY != 0) { + blocks.say.clear() + } + if (flags and CHAT != 0) { + blocks.chat.clear() + } + if (flags and EXACT_MOVE != 0) { + blocks.exactMove.clear() + } + if (flags and SPOTANIM != 0) { + blocks.spotAnims.clear() + } + if (flags and HITS != 0) { + blocks.hit.clear() + } + if (flags and TINTING != 0) { + blocks.tinting.clear() + } + } + + /** + * Resets our tracked version of the target's appearance, + * so it will be updated whenever someone else takes their index. + */ + internal fun onOtherAvatarDeallocated(idx: Int) { + otherAppearanceChangeCycles[idx] = -1 + } + + public companion object { + // Observer-dependent flags, utilizing the lowest bits as we store observer flags in a byte array + public const val APPEARANCE: Int = 0x1 + public const val MOVE_SPEED: Int = 0x2 + public const val FACE_PATHINGENTITY: Int = 0x4 + public const val TINTING: Int = 0x8 + public const val FACE_ANGLE: Int = 0x10 + + // "Static" flags, the bit values here are irrelevant + public const val SAY: Int = 0x20 + public const val HITS: Int = 0x40 + public const val SEQUENCE: Int = 0x80 + public const val CHAT: Int = 0x100 + public const val TEMP_MOVE_SPEED: Int = 0x200 + public const val EXACT_MOVE: Int = 0x400 + public const val SPOTANIM: Int = 0x800 + + private val SIGNED_BYTE_RANGE: IntRange = Byte.MIN_VALUE.toInt()..Byte.MAX_VALUE.toInt() + private val UNSIGNED_BYTE_RANGE: IntRange = UByte.MIN_VALUE.toInt()..UByte.MAX_VALUE.toInt() + private val UNSIGNED_SHORT_RANGE: IntRange = UShort.MIN_VALUE.toInt()..UShort.MAX_VALUE.toInt() + private val IDENT_KIT_RANGE: IntRange = 0..<(0x800 - 0x100) + private val OBJ_RANGE: IntRange = UShort.MIN_VALUE.toInt()..(UShort.MAX_VALUE.toInt() - 0x800) + private val UNSIGNED_SMART_1_OR_2_RANGE: IntRange = 0..0x7FFF + private val HIT_TYPE_RANGE: IntRange = -1..0x7FFD + + /** + * Executes the [block] if input verification is enabled, + * otherwise does nothing. Verification should be enabled for + * development environments, to catch problems mid-development. + * In production, or during benchmarking, verification should be disabled, + * as there is still some overhead to running verifications. + */ + private inline fun verify(crossinline block: () -> Unit) { + if (RSProtFlags.extendedInfoInputVerification) { + block() + } + } + + /** + * Builds an extended info writer array indexed by provided client types. + * All client types which are utilized must be registered to avoid runtime errors. + */ + private fun buildClientWriterArray( + extendedInfoWriters: List, + ): Array { + val array = + arrayOfNulls( + OldSchoolClientType.COUNT, + ) + for (writer in extendedInfoWriters) { + array[writer.oldSchoolClientType.id] = writer + } + return array + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfoBlocks.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfoBlocks.kt new file mode 100644 index 000000000..483d75f45 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarExtendedInfoBlocks.kt @@ -0,0 +1,75 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.ExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.encoder.PlayerExtendedInfoEncoders +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Appearance +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.Chat +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.MoveSpeed +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.PlayerTintingList +import net.rsprot.protocol.internal.game.outgoing.info.playerinfo.extendedinfo.TemporaryMoveSpeed +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.ExactMove +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FaceAngle +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.FacePathingEntity +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Hit +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Say +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.SpotAnimList + +private typealias PEnc = PlayerExtendedInfoEncoders +private typealias TempMoveSpeed = TemporaryMoveSpeed + +/** + * A data structure to bring all the extended info blocks together, + * so the information can be passed onto various client-specific encoders. + * @param writers the list of client-specific writers. + * The writers must be client-specific too, not just encoders, as + * the order in which the extended info blocks get written must follow + * the exact order described by the client. + */ +public class PlayerAvatarExtendedInfoBlocks( + writers: List>, +) { + public val appearance: Appearance = Appearance(encoders(writers, PEnc::appearance)) + public val moveSpeed: MoveSpeed = MoveSpeed(encoders(writers, PEnc::moveSpeed)) + public val temporaryMoveSpeed: TempMoveSpeed = TempMoveSpeed(encoders(writers, PEnc::temporaryMoveSpeed)) + public val sequence: Sequence = + Sequence(encoders(writers, PEnc::sequence)) + public val facePathingEntity: FacePathingEntity = FacePathingEntity(encoders(writers, PEnc::facePathingEntity)) + public val faceAngle: FaceAngle = FaceAngle(encoders(writers, PEnc::faceAngle)) + public val say: Say = Say(encoders(writers, PEnc::say)) + public val chat: Chat = Chat(encoders(writers, PEnc::chat)) + public val exactMove: ExactMove = + ExactMove( + encoders( + writers, + PEnc::exactMove, + ), + ) + public val spotAnims: SpotAnimList = SpotAnimList(encoders(writers, PEnc::spotAnim)) + public val hit: Hit = Hit(encoders(writers, PEnc::hit)) + public val tinting: PlayerTintingList = PlayerTintingList(encoders(writers, PEnc::tinting)) + + private companion object { + /** + * Builds a client-specific map of encoders for a specific extended info block, + * keyed by [OldSchoolClientType.id]. + * If a client hasn't been registered, the encoder at that index will be null. + * @param allEncoders all the client-specific extended info writers for the given type. + * @param selector a higher order function to retrieve a specific extended info block from + * the full structure of all the extended info blocks. + * @return a map of client-specific encoders of the given extended info block, + * keyed by [OldSchoolClientType.id]. + */ + private inline fun , reified E : ExtendedInfoEncoder> encoders( + allEncoders: List>, + selector: (PlayerExtendedInfoEncoders) -> E, + ): ClientTypeMap = + ClientTypeMap.ofType(allEncoders, OldSchoolClientType.COUNT) { + it.encoders.oldSchoolClientType to selector(it.encoders) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarFactory.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarFactory.kt new file mode 100644 index 000000000..5c51b8751 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerAvatarFactory.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.game.outgoing.info.filter.ExtendedInfoFilter + +public class PlayerAvatarFactory( + private val allocator: ByteBufAllocator, + private val extendedInfoFilter: ExtendedInfoFilter, + private val extendedInfoWriter: List, + private val huffmanCodec: HuffmanCodecProvider, +) { + internal fun alloc(index: Int): PlayerAvatar { + // It is possible to just pass in the extended info from here, but based on benchmarks, + // due to the field order changing, the performance will absolutely tank in doing so, + // going from ~160ms in the benchmark to around 200ms + return PlayerAvatar( + allocator, + index, + extendedInfoFilter, + extendedInfoWriter, + huffmanCodec, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt new file mode 100644 index 000000000..89180cd6f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -0,0 +1,1165 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.bitbuffer.BitBuf +import net.rsprot.buffer.bitbuffer.UnsafeLongBackedBitBuf +import net.rsprot.buffer.bitbuffer.toBitBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarPriority +import net.rsprot.protocol.game.outgoing.info.ByteBufRecycler +import net.rsprot.protocol.game.outgoing.info.ObserverExtendedInfoFlags +import net.rsprot.protocol.game.outgoing.info.exceptions.InfoProcessException +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol.Companion.PROTOCOL_CAPACITY +import net.rsprot.protocol.game.outgoing.info.playerinfo.util.CellOpcodes +import net.rsprot.protocol.game.outgoing.info.util.Avatar +import net.rsprot.protocol.game.outgoing.info.util.PacketResult +import net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfo +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.math.abs + +/** + * An implementation of the player info packet. + * This class is responsible for tracking and building the packets each cycle. + * This class utilizes [ReferencePooledObject], meaning instances of it will be pooled + * and re-used as needed, as the data stored within them is relatively memory-heavy. + * + * @param protocol the repository of all the [PlayerInfo] objects, + * as well as a source global information about everyone in the game. + * As the packet is responsible for tracking everyone in the game, + * we need to provide access to this. + * @param localIndex the index of this local player. The index corresponds to the player's slot + * in the world. The index will not change throughout the lifespan of a player, + * but can change within allocations in the reference pool. + * @param allocator the [ByteBuf] allocator responsible for allocating the primary buffer + * the is written out to the pipeline, as well as any intermediate buffers used by extended + * info blocks. The allocator should ideally be pooled, as we acquire a new instance with each + * cycle. This is because there isn't necessarily a guarantee that Netty threads have fully + * written the information out to the network by the time the next cycle comes along and starts + * writing into this buffer. A direct implementation is also preferred, as this avoids unnecessary + * copying from and to the heap. + * @param oldSchoolClientType the client on which the player is logging into. This is utilized + * to determine what encoders to use for extended info blocks. + */ +@Suppress("DuplicatedCode", "ReplaceUntilWithRangeUntil", "MemberVisibilityCanBePrivate", "EmptyRange") +public class PlayerInfo internal constructor( + private val protocol: PlayerInfoProtocol, + internal var localIndex: Int, + internal val allocator: ByteBufAllocator, + private var oldSchoolClientType: OldSchoolClientType, + public val avatar: PlayerAvatar, + private val recycler: ByteBufRecycler, + private val globalLowResolutionPositionRepository: GlobalLowResolutionPositionRepository, + private var worldEntityInfo: WorldEntityInfo?, +) : ReferencePooledObject { + /** + * Low resolution indices are tracked together with [lowResolutionCount]. + * Whenever a player enters the low resolution view, their index + * is added into this [lowResolutionIndices] array, and the [lowResolutionCount] + * is incremented by one. + * At the end of each cycle, the [lowResolutionIndices] are rebuilt to sort the indices. + */ + private val lowResolutionIndices: ShortArray = ShortArray(PROTOCOL_CAPACITY) + + /** + * The number of players in low resolution according to the protocol. + */ + private var lowResolutionCount: Int = 0 + + /** + * The tracked high resolution players by their indices. + * If a player enters our high resolution, the bit at their index is set to true. + * We do not need to use references to players as we can then refer to the [PlayerInfoRepository] + * to find the actual [PlayerInfo] implementation. + */ + private val highResolutionPlayers: LongArray = LongArray(PROTOCOL_CAPACITY ushr 6) + + /** + * High resolution indices are tracked together with [highResolutionCount]. + * Whenever an external player enters the high resolution view, their index + * is added into this [highResolutionIndices] array, and the [highResolutionCount] + * is incremented by one. + * At the end of each cycle, the [highResolutionIndices] are rebuilt to sort the indices. + */ + private val highResolutionIndices: ShortArray = ShortArray(PROTOCOL_CAPACITY) + + /** + * A bitset of high resolution players for whom we have written extended info. + * In the case of someone being added to high resolution, but due to our buffer being too + * full to actually write their extended info, we need to try again the next tick (and so on) + * until we finally succeed in synchronizing them. Without this, one could end up with invisible + * players. + */ + private val highResolutionExtendedInfoTrackedPlayers: LongArray = LongArray(PROTOCOL_CAPACITY ushr 6) + + /** + * The number of players in high resolution according to the protocol. + */ + private var highResolutionCount: Int = 0 + + /** + * The extended info indices contain pointers to all the players for whom we need to + * write an extended info block. We do this rather than directly writing them as this + * improves CPU cache locality and allows us to batch extended info blocks together. + */ + private val extendedInfoIndices: ShortArray = ShortArray(PROTOCOL_CAPACITY) + + /** + * The number of players for whom we need to write extended info blocks this cycle. + */ + private var extendedInfoCount: Int = 0 + + /** + * A bitset of high priority players. These players will be rendered above typical crowd + * when our resize range begins to decrement, as long as the given player is still within + * the preferred resize range threshold. + */ + private val highPriorityPlayers: LongArray = LongArray(PROTOCOL_CAPACITY ushr 6) + + /** + * The flags indicating the status of the players in the previous and current cycles. + * This is used to categorize players who are 'stationary', which implies they did not + * move, nor did they have any extended info blocks written for them. By batching + * players up this way, the protocol is able to skip a larger number of players + * with each skip block, as players are far more likely to be in the same state + * as they were in the last cycle. + */ + private val stationary = ByteArray(PROTOCOL_CAPACITY) + + /** + * The observer info flags are used for us to track extended info blocks which weren't necessarily + * flagged on the target player. This can happen during the transitioning from low resolution + * to high resolution, in which case appearance, move speed and face pathingentity may be transmitted, + * despite not having been flagged. Additionally, some extended info blocks, such as hits and tinting, + * will sometimes be observer-dependent. This means each observer will receive a different variant + * of the extended info buffer. A simple example of this is the red circle hitmark ironmen will + * see on NPCs whenever they attack a NPC that has already received damage from another player. + * Only the ironman will receive information about that hitmark in this case, and no one else. + */ + internal val observerExtendedInfoFlags: ObserverExtendedInfoFlags = ObserverExtendedInfoFlags(PROTOCOL_CAPACITY) + + /** + * High resolution bit buffers are cached to avoid small computations for each observer, + * and it allows us to reduce the number of [BitBuf.pBits] calls, which are quite expensive. + * This implementation will store all the information inside a 'long' primitive, as the maximum + * data size will always fit in under 50 bits. + */ + private var highResMovementBuffer: UnsafeLongBackedBitBuf? = null + + /** + * The buffer into which all the information is written in this cycle. + * It should be noted that this buffer is constantly changing, as we reallocate + * a new buffer instance through the [allocator] each cycle. This is to ensure that + * we do not start overwriting a buffer before it has been fully written into the pipeline. + * Thus, a pooled [allocator] implementation should be preferred to avoid expensive re-allocations. + */ + private var buffer: ByteBuf? = null + + /** + * The exception that was caught during the processing of this player's playerinfo packet. + * This exception will be propagated further during the [toPacketResult] function call, + * allowing the server to handle it properly at a per-player basis. + */ + @Volatile + internal var exception: Exception? = null + + /** + * The previous player info packet that was created. + * We ensure that a server hasn't accidentally left a packet unwritten, which would + * de-synchronize the client and cause errors. + */ + internal var previousPacket: PlayerInfoPacket? = null + + /** + * Returns the backing buffer for this cycle. + * @throws IllegalStateException if the buffer has not been allocated yet. + */ + @Throws(IllegalStateException::class) + private fun backingBuffer(): ByteBuf = checkNotNull(buffer) + + override fun isDestroyed(): Boolean = this.exception != null + + /** + * Sets the [otherPlayerAvatar] as high priority for us specifically. + * This means that when our local player count gets to >= 250 players, + * we will still keep that player rendered even if they are no longer within + * the range that we can still see to. It does not, however, extend past the + * preferred view range. + * @param otherPlayerAvatar the avatar to mark as high priority in relation to us. + */ + public fun setHighPriority(otherPlayerAvatar: PlayerAvatar) { + if (isDestroyed()) return + this.setHighPriority(otherPlayerAvatar.localPlayerIndex) + } + + /** + * Sets the [otherPlayerAvatar] back down to normal priority level, meaning + * they will not get preferential treatment in relation to everyone else + * at high populations, as described in [setHighPriority]. + * @param otherPlayerAvatar the avatar to mark back down to normal priority. + */ + public fun setNormalPriority(otherPlayerAvatar: PlayerAvatar) { + if (isDestroyed()) return + this.unsetHighPriority(otherPlayerAvatar.localPlayerIndex) + } + + /** + * Gets all high priority players. This only applies to players marked as high + * priority via [setHighPriority], and not avatars which have a global high + * priority status. + */ + public fun clearAllHighPriority() { + if (isDestroyed()) return + this.highPriorityPlayers.fill(0L) + } + + /** + * Checks whether the player avatar at the specified [index] is high priority. + * @param index the index of the player to check. + * @return whether the checked player is high priority. + */ + private fun isHighPriority(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + return this.highPriorityPlayers[longIndex] and bit != 0L + } + + /** + * Sets the player at the specified [index] as high priority. + * @param index the index of the player to mark as high priority. + */ + private fun setHighPriority(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highPriorityPlayers[longIndex] + this.highPriorityPlayers[longIndex] = cur or bit + } + + /** + * Sets the player at the specified [index] as normal priority. + * @param index the index of the player to mark as normal priority. + */ + internal fun unsetHighPriority(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highPriorityPlayers[longIndex] + this.highPriorityPlayers[longIndex] = cur and bit.inv() + } + + /** + * Gets the avatars of all the players that have been marked as high priority + * for us specifically. This does not include avatars which have a global high + * priority. + * @return an arraylist of all the avatars that are marked high priority to us. + */ + public fun getHighPriorityAvatars(): List { + if (isDestroyed()) return emptyList() + val players = highPriorityPlayers + val count = players.sumOf(Long::countOneBits) + if (count == 0) return emptyList() + val list = ArrayList(count) + loop@ for (i in players.indices) { + val bitpacked = players[i] + if (bitpacked == 0L) continue + val offset = i * Long.SIZE_BITS + var index = -1 + while (true) { + index = nextSetBit(index + 1, bitpacked) + if (index == -1) continue@loop + val avatar = protocol.getPlayerInfo(offset + index)?.avatar ?: continue + list.add(avatar) + } + } + return list + } + + /** + * Gets the index of the next set bit in the specified [long], starting from [index]. + * @param index the starting index to count the bits from, ignoring anything before that. + * @param long the value to find the next set bit in + * @return the index of the next set bit (value 0-63), or -1 if there are no more bits set + * after [index]. + */ + private fun nextSetBit( + index: Int, + long: Long, + ): Int { + val remaining = long and (-1L shl index) + if (remaining == 0L) return -1 + return remaining.countTrailingZeroBits() + } + + /** + * Gets the high resolution indices in a new arraylist of integers. + * The list is initialized to an initial capacity equal to the high resolution player index count. + * @return the newly created arraylist of indices + */ + public fun getHighResolutionIndices(): ArrayList { + checkCommunicationThread() + if (isDestroyed()) return ArrayList(0) + val collection = ArrayList(highResolutionCount) + for (i in 0.. appendHighResolutionIndices(collection: T): T where T : MutableCollection { + checkCommunicationThread() + if (isDestroyed()) return collection + for (i in 0.. { + return toPacketResult() + } + + /** + * Turns the previously-computed player info into a packet instance + * which can be flushed to the client, or an exception if one was thrown while + * building the packet. + * @return the player info packet instance in a [PacketResult]. + */ + @PublishedApi + internal fun toPacketResult(): PacketResult { + val exception = this.exception + if (exception != null) { + return PacketResult.failure( + InfoProcessException( + "Exception occurred during player info processing for index $localIndex", + exception, + ), + ) + } + val previousPacket = + previousPacket + ?: return PacketResult.failure( + IllegalStateException("Previous packet not available."), + ) + return PacketResult.success(previousPacket) + } + + /** + * Updates the current known coordinate of the given [Avatar]. + * This function must be called on each avatar before player info is computed. + * @param coordGrid the root world coordgrid of the player + */ + @Throws(IllegalArgumentException::class) + internal fun updateRootCoord(coordGrid: CoordGrid) { + checkCommunicationThread() + if (isDestroyed()) return + this.avatar.updateCoord(coordGrid) + } + + /** + * Checks whether the player at [index] is currently among high resolution players. + * @param index the index of the player to check. + */ + private fun isHighResolution(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + return this.highResolutionPlayers[longIndex] and bit != 0L + } + + /** + * Marks the player at index [index] as being in high resolution. + * @param index the index of the player to mark as high resolution. + */ + private fun setHighResolution(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highResolutionPlayers[longIndex] + this.highResolutionPlayers[longIndex] = cur or bit + } + + /** + * Marks the player at index [index] as being in low resolution. + * @param index the index of the player to mark as low resolution. + */ + private fun unsetHighResolution(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highResolutionPlayers[longIndex] + this.highResolutionPlayers[longIndex] = cur and bit.inv() + } + + /** + * Checks whether the player at [index] is currently among high resolution extended info players. + * @param index the index of the player to check. + */ + private fun isHighResolutionExtendedInfoTracked(index: Int): Boolean { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + return this.highResolutionExtendedInfoTrackedPlayers[longIndex] and bit != 0L + } + + /** + * Marks the player at index [index] as being in high resolution extended info. + * @param index the index of the player to mark as high resolution. + */ + private fun setHighResolutionExtendedInfoTracked(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highResolutionExtendedInfoTrackedPlayers[longIndex] + this.highResolutionExtendedInfoTrackedPlayers[longIndex] = cur or bit + } + + /** + * Marks the player at index [index] as being in low resolution extended info. + * @param index the index of the player to mark as low resolution. + */ + private fun unsetHighResolutionExtendedInfoTracked(index: Int) { + val longIndex = index ushr 6 + val bit = 1L shl (index and 0x3F) + val cur = this.highResolutionExtendedInfoTrackedPlayers[longIndex] + this.highResolutionExtendedInfoTrackedPlayers[longIndex] = cur and bit.inv() + } + + /** + * Handles initializing absolute player positions. + * @param byteBuf the buffer into which the information will be written. + */ + public fun handleAbsolutePlayerPositions(byteBuf: ByteBuf) { + checkCommunicationThread() + if (isDestroyed()) return + check(avatar.currentCoord != CoordGrid.INVALID) { + "Avatar position must be updated via playerinfo#updateCoord before sending RebuildLogin/ReconnectOk." + } + byteBuf.toBitBuf().use { buffer -> + buffer.pBits(30, avatar.currentCoord.packed) + setHighResolution(localIndex) + highResolutionIndices[highResolutionCount++] = localIndex.toShort() + for (i in 1 until PROTOCOL_CAPACITY) { + if (i == localIndex) { + continue + } + val lowResolutionPosition = protocol.getLowResolutionPosition(i) + buffer.pBits(18, lowResolutionPosition.packed) + lowResolutionIndices[lowResolutionCount++] = i.toShort() + } + } + // Sync the coordinate delta here! + // Meaning if a player info is sent afterwards, it will not re-send the delta + // which often results in the coordinate being 2x'd at the client + avatar.postUpdate() + } + + /** + * Resets any existing state. + * Cached state should be re-assigned from the server as a result of this. + */ + public fun onReconnect() { + checkCommunicationThread() + if (isDestroyed()) return + buffer = null + highResMovementBuffer = null + previousPacket = null + lowResolutionIndices.fill(0) + lowResolutionCount = 0 + highResolutionIndices.fill(0) + highResolutionCount = 0 + highResolutionPlayers.fill(0L) + highResolutionExtendedInfoTrackedPlayers.fill(0L) + extendedInfoCount = 0 + extendedInfoIndices.fill(0) + stationary.fill(0) + observerExtendedInfoFlags.reset() + avatar.postUpdate() + avatar.extendedInfo.onReconnect() + } + + /** + * Ensures that the state has been correctly reset and a reconnect packet can continue. + * @throws IllegalStateException if the state has not fully been cleaned up. + */ + internal fun ensureReconnectCalled() { + if (!isCleanState()) { + throw IllegalStateException( + "In order to use LoginResponse.ReconnectOk packet, " + + "playerinfo#onReconnect, npcInfo#onReconnect " + + "and worldEntityInfo#onReconnect must be called!", + ) + } + } + + /** + * Checks whether all the info has been reset for this packet, ensuring that + * a reconnect packet can successfully be initialized. + * @return whether all the state has been reset. + */ + private fun isCleanState(): Boolean = + buffer == null && + highResMovementBuffer == null && + lowResolutionCount == 0 && + highResolutionCount == 0 && + extendedInfoCount == 0 + + /** + * Precalculates all the bitcodes for this player, for high-resolution updates. + * This function will be thread-safe relative to other players and can be calculated concurrently for all players. + */ + internal fun prepareBitcodes() { + this.avatar.extendedInfo.observedChatStorage + .reset() + this.highResMovementBuffer = prepareHighResMovement() + } + + /** + * Pre-computes extended info blocks for this player. Only extended info blocks + * which were flagged during this cycle will be pre-computed, with any on-demand + * extended info blocks excluded in pre-computations altogether. + */ + internal fun precomputeExtendedInfo() { + avatar.extendedInfo.precompute() + } + + /** + * Writes the extended info blocks of everyone who were marked + * during [pBitcodes] to the [buffer]. This will utilize fast native memory copying for any + * pre-computed extended info blocks. For any observer-dependent info blocks, + * a new [ByteBuf] instance is allocated from the [allocator], which is then written + * the information, followed by a fast native copy, which is further followed by releasing + * this temporary buffer back. As mentioned before, it is highly suggested to use a pooled + * implementation of the [allocator]. + * This function is thread-safe relative to other players and can be computed for all players + * concurrently. + */ + internal fun putExtendedInfo() { + val jagBuffer = backingBuffer().toJagByteBuf() + for (i in 0 until extendedInfoCount) { + val index = extendedInfoIndices[i].toInt() + val other = protocol.getPlayerInfo(index) + // If other is null at this point, it means it was destroyed mid-processing at an earlier + // stage. In order to avoid the issue escalating further by throwing errors for every player + // that was in vicinity of the player that got destroyed, we simply write no-mask-update, + // even though a mask update was requested at an earlier stage. + // The next game tick, the player will be removed as the info is null, which is one of + // the conditions for removing another player from tracking. + if (other == null) { + jagBuffer.p1(0) + continue + } + val observerFlag = observerExtendedInfoFlags.getFlag(index) + val isHighResolutionTracked = isHighResolutionExtendedInfoTracked(index) + val tracked = + other.avatar.extendedInfo.pExtendedInfo( + oldSchoolClientType, + jagBuffer, + observerFlag, + avatar.extendedInfo, + extendedInfoCount - i, + !isHighResolutionTracked, + ) + if (!isHighResolutionTracked && tracked) { + setHighResolutionExtendedInfoTracked(index) + } + } + } + + /** + * Writes to the actual buffers the prepared bitcodes and extended information. + * This function will be thread-safe relative to other players and can be calculated concurrently for all players. + */ + internal fun pBitcodes() { + avatar.resize(highResolutionCount) + val buffer = allocBuffer() + val bitBuf = buffer.toBitBuf() + bitBuf.use { processHighResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(it, skipStationary = false) } + bitBuf.use { processLowResolution(it, skipStationary = false) } + bitBuf.use { processLowResolution(it, skipStationary = true) } + } + + /** + * Processes low resolution updates for all the players who are currently + * in our low resolution view. + * @param buffer the buffer into which to write the bitcodes regarding each player. + * @param skipStationary whether to skip any players who were marked as stationary last cycle. + */ + private fun processLowResolution( + buffer: BitBuf, + skipStationary: Boolean, + ) { + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + var skips = -1 + for (i in 0 until lowResolutionCount) { + val index = lowResolutionIndices[i].toInt() + val wasStationary = stationary[index].toInt() and WAS_STATIONARY != 0 + if (skipStationary == wasStationary) { + continue + } + val other = protocol.getPlayerInfo(index) + val lowResolutionMovementBuffer = globalLowResolutionPositionRepository.getBuffer(index) + if (other == null) { + if (lowResolutionMovementBuffer != null) { + if (skips > -1) { + pStationary(buffer, skips) + skips = -1 + } + buffer.pBits(1, 1) + buffer.pBits(lowResolutionMovementBuffer) + continue + } + skips++ + stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() + continue + } + val visible = shouldMoveToHighResolution(worldEntityInfo, other) + if (!visible && lowResolutionMovementBuffer == null) { + skips++ + stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() + continue + } + if (skips > -1) { + pStationary(buffer, skips) + skips = -1 + } + if (!visible) { + buffer.pBits(1, 1) + buffer.pBits(lowResolutionMovementBuffer!!) + continue + } + pLowResToHighRes(buffer, other) + } + if (skips > -1) { + pStationary(buffer, skips) + } + } + + /** + * Writes a transition from low resolution to high resolution for the given player. + * @param buffer the buffer into which to write the transition. + * @param other the player who is being moved from low resolution to high resolution. + */ + private fun pLowResToHighRes( + buffer: BitBuf, + other: PlayerInfo, + ) { + val index = other.localIndex + // The above one-liner pBits is equal to this comment: + // buffer.pBits(1, 1) + // buffer.pBits(2, 0) + buffer.pBits(3, 1 shl 2) + val lowResBuf = globalLowResolutionPositionRepository.getBuffer(index) + if (lowResBuf != null) { + buffer.pBits(1, 1) + buffer.pBits(lowResBuf) + } else { + buffer.pBits(1, 0) + } + val (_, x, z) = other.avatar.currentCoord + + buffer.pBits(13, x) + buffer.pBits(13, z) + + // Get a flags of all the extended info blocks that are 'outdated' to us and must be sent again. + val extraFlags = + other.avatar.extendedInfo.getLowToHighResChangeExtendedInfoFlags( + avatar.extendedInfo, + oldSchoolClientType, + ) + // Mark those flags as observer-dependent. + observerExtendedInfoFlags.addFlag(index, extraFlags) + stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() + setHighResolution(index) + val flag = other.avatar.extendedInfo.flags or observerExtendedInfoFlags.getFlag(index) + val hasExtendedInfoBlock = flag != 0 + if (hasExtendedInfoBlock) { + extendedInfoIndices[extendedInfoCount++] = index.toShort() + buffer.pBits(1, 1) + } else { + setHighResolutionExtendedInfoTracked(index) + buffer.pBits(1, 0) + } + } + + /** + * Processes high resolution updates for all the players who are currently + * in our high resolution view. + * @param buffer the buffer into which to write the bitcodes regarding each player. + * @param skipStationary whether to skip any players who were marked as stationary last cycle. + */ + private fun processHighResolution( + buffer: BitBuf, + skipStationary: Boolean, + ) { + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + var skips = -1 + for (i in 0 until highResolutionCount) { + val index = highResolutionIndices[i].toInt() + val wasStationary = (stationary[index].toInt() and WAS_STATIONARY) != 0 + if (skipStationary == wasStationary) { + continue + } + val other = protocol.getPlayerInfo(index) + if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (skips > -1) { + pStationary(buffer, skips) + skips = -1 + } + pHighToLowResChange(buffer, index) + continue + } + + // If we still haven't tracked extended info for them, re-try + if (!isHighResolutionExtendedInfoTracked(index)) { + val extraFlags = + other.avatar.extendedInfo.getLowToHighResChangeExtendedInfoFlags( + avatar.extendedInfo, + oldSchoolClientType, + ) + observerExtendedInfoFlags.addFlag(index, extraFlags) + } + val flag = other.avatar.extendedInfo.flags or observerExtendedInfoFlags.getFlag(index) + val hasExtendedInfoBlock = flag != 0 + if (!hasExtendedInfoBlock) { + setHighResolutionExtendedInfoTracked(index) + } + val highResBuf = other.highResMovementBuffer + val skipped = !hasExtendedInfoBlock && highResBuf == null + if (!skipped) { + if (skips > -1) { + pStationary(buffer, skips) + skips = -1 + } + pHighRes(buffer, index, hasExtendedInfoBlock, highResBuf) + continue + } + skips++ + stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() + } + if (skips > -1) { + pStationary(buffer, skips) + } + } + + /** + * Writes the [count] of consecutive stationary players + * using [run-length encoding](https://en.wikipedia.org/wiki/Run-length_encoding). + * @param buffer the buffer into which to write the encoded count. + * @param count the count of players that were skipped. + * The actual number that is written will always be 1 less, as the client automatically + * includes 1 in the total value through the presence of a stationary block in the first place. + */ + private fun pStationary( + buffer: BitBuf, + count: Int, + ) { + // The below code is a branchless variant of this: + // buffer.pBits(1, 0) + // when { + // count == 0 -> buffer.pBits(2, 0) + // count <= 0x1F -> { + // buffer.pBits(2, 1) + // buffer.pBits(5, count) + // } + // count <= 0xFF -> { + // buffer.pBits(2, 2) + // buffer.pBits(8, count) + // } + // else -> { + // buffer.pBits(2, 3) + // buffer.pBits(11, count) + // } + // } + // + // The branching causes a significant (~15-20%) performance loss in the extreme + // end-case benchmarks, so it's best to eliminate it. + + // (Special thanks to Greg for figuring out the magic below!) + // Positive signum the bits proceeding the 1st, 5th and 8th bit to give a value 1 - 3 to + // represent > 0, > 31 and > 255 respectively. + val lowerBits = (-count ushr 31) + val higherBits = (-(count shr 5) ushr 31) + (-(count shr 8) ushr 31) + val bitCountOpcode = lowerBits + higherBits + val valueBitCount = (lowerBits * 5) + (higherBits * 3) + buffer.pBits(3 + valueBitCount, count or (bitCountOpcode shl valueBitCount)) + } + + /** + * Writes high resolution information about a player into the [buffer]. + * @param buffer the buffer into which to write the bitcodes. + * @param index the index of the player whose information we are writing. + * @param extendedInfo whether this player also had extended info block changes. + * @param highResBuf the pre-computed bit buffer regarding this player's movement. + */ + private fun pHighRes( + buffer: BitBuf, + index: Int, + extendedInfo: Boolean, + highResBuf: UnsafeLongBackedBitBuf?, + ) { + buffer.pBits(1, 1) + if (extendedInfo) { + extendedInfoIndices[extendedInfoCount++] = index.toShort() + buffer.pBits(1, 1) + } else { + buffer.pBits(1, 0) + } + if (highResBuf != null) { + buffer.pBits(highResBuf) + } else { + buffer.pBits(2, 0) + } + } + + /** + * Writes a high resolution to low resolution change for the player. + * @param buffer the buffer into which to write the bitcodes. + * @param index the index of the player that is being moved to low resolution. + */ + private fun pHighToLowResChange( + buffer: BitBuf, + index: Int, + ) { + unsetHighResolution(index) + unsetHighResolutionExtendedInfoTracked(index) + // The one-liner pBits is equal to the below comment: + // buffer.pBits(1, 1) + // buffer.pBits(1, 0) + // buffer.pBits(2, 0) + buffer.pBits(4, 1 shl 3) + val lowResolutionMovementBuffer = globalLowResolutionPositionRepository.getBuffer(index) + if (lowResolutionMovementBuffer != null) { + buffer.pBits(1, 1) + buffer.pBits(lowResolutionMovementBuffer) + } else { + buffer.pBits(1, 0) + } + } + + /** + * Checks if [other] is visible to us considering our [PlayerAvatar.resizeRange]. + * This function utilizes experimental contracts to avoid an unnecessary null-check, + * as if the function returns true, the parameter cannot ever be null. + * @param other the player whom to check. + * @return true if the other should be moved to low resolution. + */ + @OptIn(ExperimentalContracts::class) + private fun shouldStayInHighResolution( + worldEntityInfo: WorldEntityInfo, + other: PlayerInfo?, + ): Boolean { + contract { + returns(true) implies (other != null) + } + // If the avatar is no longer logged in, remove it + if (other == null) { + return false + } + // Do not add or remove local player + if (other.localIndex == localIndex) { + return true + } + if (other.avatar.hidden) { + return false + } + // If the avatar was allocated on this cycle, ensure we remove (and potentially re-add later) + // this avatar. This is due to someone logging out and another player taking the avatar the same + // cycle - which would otherwise potentially go by unnoticed, with the client assuming nothing changed. + if (other.avatar.allocateCycle == PlayerInfoProtocol.cycleCount) { + return false + } + val otherCoordGrid = other.avatar.currentCoord + val rangeToCheck = + if (other.avatar.priority == AvatarPriority.NORMAL || + isHighPriority(other.avatar.localPlayerIndex) + ) { + this.avatar.preferredResizeRange + } else { + this.avatar.resizeRange + } + return rangeToCheck == Int.MAX_VALUE || + worldEntityInfo.isVisible( + avatar.currentCoord, + otherCoordGrid, + rangeToCheck, + ) + } + + /** + * Checks if [other] is visible to us considering our [PlayerAvatar.resizeRange]. + * This function utilizes experimental contracts to avoid an unnecessary null-check, + * as if the function returns true, the parameter cannot ever be null. + * @param other the player whom to check. + * @return true if the other player should be moved to high resolution. + */ + @OptIn(ExperimentalContracts::class) + private fun shouldMoveToHighResolution( + worldEntityInfo: WorldEntityInfo, + other: PlayerInfo?, + ): Boolean { + contract { + returns(true) implies (other != null) + } + // If the avatar is no longer logged in, remove it + if (other == null || other.localIndex == localIndex) { + return false + } + if (other.avatar.hidden) { + return false + } + val otherCoordGrid = other.avatar.currentCoord + val rangeToCheck = + if (other.avatar.priority == AvatarPriority.NORMAL || + isHighPriority(other.avatar.localPlayerIndex) + ) { + this.avatar.preferredResizeRange + } else { + this.avatar.resizeRange + } + return rangeToCheck == Int.MAX_VALUE || + worldEntityInfo.isVisible( + avatar.currentCoord, + otherCoordGrid, + rangeToCheck, + ) + } + + /** + * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. + * The old [buffer] will not be released, as that is the duty of the encoder class. + */ + private fun allocBuffer(): ByteBuf { + // Acquire a new buffer with each cycle, in case the previous one isn't fully written out yet + val buffer = allocator.buffer(BUF_CAPACITY, BUF_CAPACITY) + this.buffer = buffer + recycler += buffer + return buffer + } + + /** + * Reset any temporary properties from this cycle. + */ + internal fun postUpdate() { + this.avatar.postUpdate() + avatar.extendedInfo.postUpdate() + lowResolutionCount = 0 + highResolutionCount = 0 + // Only need to reset the count here, the actual numbers don't matter. + extendedInfoCount = 0 + for (i in 1 until PROTOCOL_CAPACITY) { + stationary[i] = (stationary[i].toInt() shr 1).toByte() + if (isHighResolution(i)) { + highResolutionIndices[highResolutionCount++] = i.toShort() + } else { + lowResolutionIndices[lowResolutionCount++] = i.toShort() + } + } + observerExtendedInfoFlags.reset() + avatar.extendedInfo.postUpdate() + if (this.previousPacket?.isConsumed() == false) { + logger.warn { + "Previous player info packet was calculated but not sent out to the client for index $localIndex!" + } + } + val packet = PlayerInfoPacket(backingBuffer()) + this.previousPacket = packet + } + + /** + * Resets all the primitive properties of this class which can be lazy-reset. + * We utilize lazy resetting here as there's no guarantee that a given [PlayerInfo] + * object will ever be re-used. Due to the nature of soft references, it is possible + * for the garbage collector to collect it when it truly needs it. In order to reduce processing + * time, we skip resetting these properties on de-allocation. + * @param index the index of the new player who will be utilizing this player info object. + * @param oldSchoolClientType the client the new player is utilizing. + */ + override fun onAlloc( + index: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) { + this.localIndex = index + avatar.localPlayerIndex = index + avatar.extendedInfo.localIndex = index + this.oldSchoolClientType = oldSchoolClientType + avatar.reset() + this.avatar.allocateCycle = PlayerInfoProtocol.cycleCount + lowResolutionIndices.fill(0) + lowResolutionCount = 0 + highResolutionIndices.fill(0) + highResolutionCount = 0 + highResolutionPlayers.fill(0L) + highResolutionExtendedInfoTrackedPlayers.fill(0L) + highPriorityPlayers.fill(0L) + extendedInfoCount = 0 + extendedInfoIndices.fill(0) + stationary.fill(0) + observerExtendedInfoFlags.reset() + buffer = null + previousPacket = null + } + + /** + * Clears any references to temporary buffers on de-allocation, as we don't want these + * to stick around for extended periods of time. Any primitive properties will remain untouched. + */ + override fun onDealloc() { + this.buffer = null + this.previousPacket = null + avatar.extendedInfo.reset() + highResMovementBuffer = null + this.worldEntityInfo = null + } + + /** + * Prepares the high resolution movement block by checking the player's absolute coordinate + * differences. + * @return unsafe long-backed bit buffer that encodes the information into a 'long' primitive, + * rather than a real byte buffer, in order to reduce unnecessary computations. + */ + private fun prepareHighResMovement(): UnsafeLongBackedBitBuf? { + val oldCoord = avatar.lastCoord + val newCoord = avatar.currentCoord + if (oldCoord == newCoord) { + return null + } + val buffer = UnsafeLongBackedBitBuf() + val deltaX = newCoord.x - oldCoord.x + val deltaZ = newCoord.z - oldCoord.z + val deltaLevel = newCoord.level - oldCoord.level + val absX = abs(deltaX) + val absZ = abs(deltaZ) + if (deltaLevel != 0 || absX > 2 || absZ > 2) { + if (absX >= 16 || absZ >= 16) { + pLargeTeleport(buffer, deltaX, deltaZ, deltaLevel) + } else { + pSmallTeleport(buffer, deltaX, deltaZ, deltaLevel) + } + } else if (absX == 2 || absZ == 2) { + pRun(buffer, deltaX, deltaZ) + } else { + // Guaranteed to be walking here, as our 'oldCoord == newCoord' covers the stationary condition. + pWalk(buffer, deltaX, deltaZ) + } + return buffer + } + + /** + * Writes a single cell movement bitcode. + * @param buffer the buffer into which to write the bitcode. + * @param deltaX the x-coordinate delta the player moved. + * @param deltaZ the z-coordinate delta the player moved. + * @throws ArrayIndexOutOfBoundsException if the provided deltas do not result in a + * one-cell movement. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + private fun pWalk( + buffer: UnsafeLongBackedBitBuf, + deltaX: Int, + deltaZ: Int, + ) { + buffer.pBits(2, 1) + buffer.pBits(3, CellOpcodes.singleCellMovementOpcode(deltaX, deltaZ)) + } + + /** + * Writes a dual cell movement bitcode. + * @param buffer the buffer into which to write the bitcode. + * @param deltaX the x-coordinate delta the player moved. + * @param deltaZ the z-coordinate delta the player moved. + * @throws ArrayIndexOutOfBoundsException if the provided deltas do not result in a + * dual-cell movement. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + private fun pRun( + buffer: UnsafeLongBackedBitBuf, + deltaX: Int, + deltaZ: Int, + ) { + buffer.pBits(2, 2) + buffer.pBits(4, CellOpcodes.dualCellMovementOpcode(deltaX, deltaZ)) + } + + /** + * Writes a low-distance movement block, capped to a maximum delta of 15 coordinates + * as well as any level changes. + * @param buffer the buffer into which to write the bitcode. + * @param deltaX the x-coordinate delta the player moved. + * @param deltaZ the z-coordinate delta the player moved. + * @param deltaLevel the level-coordinate delta the player moved. + */ + private fun pSmallTeleport( + buffer: UnsafeLongBackedBitBuf, + deltaX: Int, + deltaZ: Int, + deltaLevel: Int, + ) { + buffer.pBits(2, 3) + buffer.pBits(1, 0) + buffer.pBits(2, deltaLevel and 0x3) + buffer.pBits(5, deltaX and 0x1F) + buffer.pBits(5, deltaZ and 0x1F) + } + + /** + * Writes a long-distance movement block, completely uncapped for the game world. + * @param buffer the buffer into which to write the bitcode. + * @param deltaX the x-coordinate delta the player moved. + * @param deltaZ the z-coordinate delta the player moved. + * @param deltaLevel the level-coordinate delta the player moved. + */ + private fun pLargeTeleport( + buffer: UnsafeLongBackedBitBuf, + deltaX: Int, + deltaZ: Int, + deltaLevel: Int, + ) { + buffer.pBits(2, 3) + buffer.pBits(1, 1) + buffer.pBits(2, deltaLevel and 0x3) + buffer.pBits(14, deltaX and 0x3FFF) + buffer.pBits(14, deltaZ and 0x3FFF) + } + + public companion object { + /** + * The default capacity of the backing byte buffer into which all player info is written. + */ + private const val BUF_CAPACITY: Int = 40_000 + + /** + * The flag indicating that a player was stationary in the previous cycle. + */ + private const val WAS_STATIONARY: Int = 0x1 + + /** + * The flag indicating that a player is stationary in the current cycle. + */ + private const val IS_STATIONARY: Int = 0x2 + + /** + * The constant id for the root world. + */ + public const val ROOT_WORLD: Int = -1 + + private val logger = InlineLogger() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoPacket.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoPacket.kt new file mode 100644 index 000000000..d340103bb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoPacket.kt @@ -0,0 +1,41 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.ConsumableMessage +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * A npc info packet class, wrapped in its own byte buf holder as the packet encoder is only + * invoked through Netty threads, therefore it is not safe to strictly pass the reference + * from player info itself. + */ +public class PlayerInfoPacket( + buffer: ByteBuf, +) : DefaultByteBufHolder(buffer), + OutgoingGameMessage, + ConsumableMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + private var consumed: Boolean = false + + override fun consume() { + this.consumed = true + } + + override fun isConsumed(): Boolean = this.consumed + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + return true + } + + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = super.toString() +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt new file mode 100644 index 000000000..4dc3ef68e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -0,0 +1,299 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.ByteBufRecycler +import net.rsprot.protocol.game.outgoing.info.playerinfo.util.LowResolutionPosition +import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityInfo +import net.rsprot.protocol.internal.checkCommunicationThread +import java.util.concurrent.Callable +import java.util.concurrent.ForkJoinPool +import kotlin.Exception + +/** + * The player info protocol is responsible for tracking everything player info related + * within the given world. This class holds every avatar, their state, and provides + * means to allocate new player info instances. + * @param allocator the [ByteBuf] allocator responsible for allocating the primary buffer + * the is written out to the pipeline, as well as any intermediate buffers used by extended + * info blocks. The allocator should ideally be pooled, as we acquire a new instance with each + * cycle. This is because there isn't necessarily a guarantee that Netty threads have fully + * written the information out to the network by the time the next cycle comes along and starts + * writing into this buffer. A direct implementation is also preferred, as this avoids unnecessary + * copying from and to the heap. + * @param worker the worker responsible for executing the blocks of code found in player info. + * The default worker will remain single-threaded if there are less than `coreCount * 4` players + * in the world. Otherwise, it will use [ForkJoinPool] to execute these jobs. Both of these + * are configurable within the [DefaultProtocolWorker] constructor. + */ +public class PlayerInfoProtocol( + private val allocator: ByteBufAllocator, + private val worker: ProtocolWorker = DefaultProtocolWorker(), + private val avatarFactory: PlayerAvatarFactory, +) { + /** + * A recycler to ensure all buffers allocated by player info eventually get released. + */ + private val recycler: ByteBufRecycler = ByteBufRecycler() + + /** + * The repository responsible for keeping track of all the players' low resolution + * position within the world. + */ + private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = + GlobalLowResolutionPositionRepository() + + /** + * The repository responsible for allocating and storing player info instances of + * all the avatars that exist. + */ + private val playerInfoRepository: PlayerInfoRepository = + PlayerInfoRepository { localIndex, clientType, worldEntityInfo -> + PlayerInfo( + this, + localIndex, + allocator, + clientType, + avatarFactory.alloc(localIndex), + recycler, + lowResolutionPositionRepository, + worldEntityInfo, + ) + } + + /** + * The list of [Callable] instances which perform the jobs for player info. + * This list itself is re-used throughout the lifespan of the application, + * but the [Callable] instances themselves are generated for every job. + */ + private val callables: MutableList> = ArrayList(PROTOCOL_CAPACITY) + + /** + * Gets the current element at index [idx], or null if it doesn't exist. + * @param idx the index of the player info object to obtain + * @throws ArrayIndexOutOfBoundsException if the index is below zero, + * or above [PlayerInfoProtocol.PROTOCOL_CAPACITY]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + internal fun getPlayerInfo(idx: Int): PlayerInfo? = playerInfoRepository.getOrNull(idx) + + /** + * Allocates a new player info instance at index [idx] + * @param idx the index of the player to allocate + * @param oldSchoolClientType the client on which this player is. + * The client type is used to determine which extended info encoders to utilize + * when building the buffers for this packet. + * @throws ArrayIndexOutOfBoundsException if the [idx] is below 1, or above 2047. + * @throws IllegalStateException if the element at index [idx] is already in use. + */ + @Throws( + ArrayIndexOutOfBoundsException::class, + IllegalStateException::class, + ) + internal fun alloc( + idx: Int, + oldSchoolClientType: OldSchoolClientType, + worldEntityInfo: WorldEntityInfo, + ): PlayerInfo { + checkCommunicationThread() + // Only handle index 0 as a special case, as the protocol + // does not allow putting an avatar at index 0. + // Other index exceptions are handled by the alloc function. + if (idx == 0) { + throw ArrayIndexOutOfBoundsException("Index 0 is not valid for player info protocol.") + } + return playerInfoRepository.alloc(idx, oldSchoolClientType, worldEntityInfo) + } + + /** + * Deallocates the player info object, releasing it back into the pool to be used by another player. + * @param info the player info object + */ + internal fun dealloc(info: PlayerInfo) { + checkCommunicationThread() + // Prevent returning a destroyed player info object back into the pool + if (info.isDestroyed()) { + return + } + val index = info.localIndex + // Reset the high priority property on everyone that might be observing us + // Including ourselves (not that this matters, but since we allow it, we must do + // this step before the dealloc call). + for (i in 0.. Unit) { + for (i in 1.. PlayerInfo, +) : InfoRepository(allocator) { + /** + * The backing elements array used to store currently-in-use objects. + */ + override val elements: Array = arrayOfNulls(PlayerInfoProtocol.PROTOCOL_CAPACITY) + + override fun informDeallocation(idx: Int) { + for (element in elements) { + if (element == null) { + continue + } + element.avatar.extendedInfo.onOtherAvatarDeallocated(idx) + } + } + + override fun onDealloc(element: PlayerInfo) { + element.onDealloc() + } + + override fun onAlloc( + element: PlayerInfo, + idx: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) { + element.onAlloc(idx, oldSchoolClientType, newInstance) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoWorldDetails.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoWorldDetails.kt new file mode 100644 index 000000000..338d1d715 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoWorldDetails.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo + +import net.rsprot.protocol.game.outgoing.info.util.BuildArea +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +/** + * A class which wraps the details of a player info implementation in a specific world. + * @property worldId the id of the world this info object is tracking. + */ +internal class PlayerInfoWorldDetails( + internal var worldId: Int, +) { + /** + * The coordinate from which distance checks are done against other players. + */ + internal var renderCoord: CoordGrid = CoordGrid.INVALID + + /** + * The entire build area of this world - this effectively caps what we can see + * to be within this block of land. Anything outside will be excluded. + */ + internal var buildArea: BuildArea = BuildArea.INVALID + + internal fun onAlloc(worldId: Int) { + this.worldId = worldId + this.renderCoord = CoordGrid.INVALID + this.buildArea = BuildArea.INVALID + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/CellOpcodes.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/CellOpcodes.kt new file mode 100644 index 000000000..142f2d3a2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/CellOpcodes.kt @@ -0,0 +1,147 @@ +@file:Suppress("DuplicatedCode") + +package net.rsprot.protocol.game.outgoing.info.playerinfo.util + +/** + * An object containing all the opcodes used for transmission in player info protocol. + * These opcodes are used as a means to compress short-distance movement deltas sent to the client. + */ +internal object CellOpcodes { + // Single cell opcodes + private const val SW: Int = 0 + private const val S: Int = 1 + private const val SE: Int = 2 + private const val W: Int = 3 + private const val E: Int = 4 + private const val NW: Int = 5 + private const val N: Int = 6 + private const val NE: Int = 7 + + // Dual cell opcodes (each letter stands for 1 cell in that direction, SSWW = 2 tiles south, 2 tiles west) + private const val SSWW: Int = 0 + private const val SSW: Int = 1 + private const val SS: Int = 2 + private const val SSE: Int = 3 + private const val SSEE: Int = 4 + private const val SWW: Int = 5 + private const val SEE: Int = 6 + private const val WW: Int = 7 + private const val EE: Int = 8 + private const val NWW: Int = 9 + private const val NEE: Int = 10 + private const val NNWW: Int = 11 + private const val NNW: Int = 12 + private const val NN: Int = 13 + private const val NNE: Int = 14 + private const val NNEE: Int = 15 + + /** + * Single cell movement opcodes in a len-16 array. + */ + private val singleCellMovementOpcodes: IntArray = buildSingleCellMovementOpcodes() + + /** + * Dual cell movement opcodes in a len-64 array. + */ + private val dualCellMovementOpcodes: IntArray = buildDualCellMovementOpcodes() + + /** + * Gets the index for a single cell movement opcode based on the deltas, + * where the deltas are expected to be either -1, 0 or 1. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the index of the single cell opcode stored in [singleCellMovementOpcodes] + */ + private fun singleCellIndex( + deltaX: Int, + deltaZ: Int, + ): Int = (deltaX + 1).or((deltaZ + 1) shl 2) + + /** + * Gets the index of the dual cell movement opcode based on the deltas, + * where the deltas are expected to be in range of -2..2. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the index of the dual cell opcode stored in [dualCellMovementOpcodes] + */ + private fun dualCellIndex( + deltaX: Int, + deltaZ: Int, + ): Int = (deltaX + 2).or((deltaZ + 2) shl 3) + + /** + * Gets the single cell movement opcode value for the provided deltas. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the movement opcode as expected by the client, or -1 if the deltas are in range, + * but the deltas do not result in any movement. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -1..1. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + internal fun singleCellMovementOpcode( + deltaX: Int, + deltaZ: Int, + ): Int = singleCellMovementOpcodes[singleCellIndex(deltaX, deltaZ)] + + /** + * Gets the dual cell movement opcode value for the provided deltas. + * @param deltaX the x-coordinate delta + * @param deltaZ the z-coordinate delta + * @return the movement opcode as expected by the client, or -1 if the deltas are in range, + * but the deltas do not result in any movement. + * @throws ArrayIndexOutOfBoundsException if either of the deltas is not in range of -2..2. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + internal fun dualCellMovementOpcode( + deltaX: Int, + deltaZ: Int, + ): Int = dualCellMovementOpcodes[dualCellIndex(deltaX, deltaZ)] + + /** + * Builds a simple bitpacked array of the bit codes for all the possible deltas. + * This is simply a more efficient variant of the normal if-else chain of checking + * the different delta combinations, as we are skipping a lot of branch prediction. + * In a benchmark, the results showed ~603% increased performance. + */ + private fun buildSingleCellMovementOpcodes(): IntArray { + val array = IntArray(16) { -1 } + array[singleCellIndex(-1, -1)] = SW + array[singleCellIndex(0, -1)] = S + array[singleCellIndex(1, -1)] = SE + array[singleCellIndex(-1, 0)] = W + array[singleCellIndex(1, 0)] = E + array[singleCellIndex(-1, 1)] = NW + array[singleCellIndex(0, 1)] = N + array[singleCellIndex(1, 1)] = NE + return array + } + + /** + * Similarly to [buildSingleCellMovementOpcodes], this is significantly more efficient + * than chained if-else statements. + * In this case, as there are more branches, the benchmark showed a 891% performance increase. + * It is worth noting that the benchmark in question also included reading deltas from + * a pre-computed array and thus, the real gain would actually be even more significant if only + * comparing the raw time taken by reading the opcode alone. + */ + private fun buildDualCellMovementOpcodes(): IntArray { + val array = IntArray(64) { -1 } + array[dualCellIndex(-2, -2)] = SSWW + array[dualCellIndex(-1, -2)] = SSW + array[dualCellIndex(0, -2)] = SS + array[dualCellIndex(1, -2)] = SSE + array[dualCellIndex(2, -2)] = SSEE + array[dualCellIndex(-2, -1)] = SWW + array[dualCellIndex(2, -1)] = SEE + array[dualCellIndex(-2, 0)] = WW + array[dualCellIndex(2, 0)] = EE + array[dualCellIndex(-2, 1)] = NWW + array[dualCellIndex(2, 1)] = NEE + array[dualCellIndex(-2, 2)] = NNWW + array[dualCellIndex(-1, 2)] = NNW + array[dualCellIndex(0, 2)] = NN + array[dualCellIndex(1, 2)] = NNE + array[dualCellIndex(2, 2)] = NNEE + return array + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/LowResolutionPosition.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/LowResolutionPosition.kt new file mode 100644 index 000000000..cd3ddbc9c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/util/LowResolutionPosition.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.game.outgoing.info.playerinfo.util + +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +/** + * A value class for holding low resolution position information in a primitive int. + * @param packed the bitpacked representation of the low resolution position + */ +@JvmInline +internal value class LowResolutionPosition( + val packed: Int, +) { + val x: Int + get() = packed ushr 8 and 0xFF + val z: Int + get() = packed and 0xFF + val level: Int + get() = packed ushr 16 and 0x3 +} + +/** + * A fake constructor for the low resolution position value class, as the JVM signature + * matches that of the primary constructor. + * @param coordGrid the absolute coordinate to turn into a low resolution position. + * @return the low resolution representation of the given [coordGrid] + */ +internal fun LowResolutionPosition(coordGrid: CoordGrid): LowResolutionPosition = + LowResolutionPosition( + (coordGrid.z ushr 13) + .or((coordGrid.x ushr 13) shl 8) + .or((coordGrid.level shl 16)), + ) diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/Avatar.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/Avatar.kt new file mode 100644 index 000000000..984cab65a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/Avatar.kt @@ -0,0 +1,10 @@ +package net.rsprot.protocol.game.outgoing.info.util + +public interface Avatar { + /** + * Handles any changes to be done to the avatar post its update. + * This will clean up any extended info blocks and update the last coordinate to + * match up with the current (set earlier in the cycle). + */ + public fun postUpdate() +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/BuildArea.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/BuildArea.kt new file mode 100644 index 000000000..cc332b9fc --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/BuildArea.kt @@ -0,0 +1,133 @@ +package net.rsprot.protocol.game.outgoing.info.util + +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +/** + * The build area class is responsible for tracking the currently-rendered + * map of a given player. Everything sent via world entity info is tracked + * as relative to the build area. + * @property zoneX the south-western zone x coordinate of the build area + * @property zoneZ the south-western zone z coordinate of the build area + * @property widthInZones the build area width in zones (typically 13, meaning 104 tiles) + * @property heightInZones the build area height in zones (typically 13, meaning 104 tiles) + */ +@Suppress("MemberVisibilityCanBePrivate") +@JvmInline +public value class BuildArea private constructor( + private val packed: Long, +) { + public constructor( + zoneX: Int, + zoneZ: Int, + widthInZones: Int = DEFAULT_BUILD_AREA_SIZE, + heightInZones: Int = DEFAULT_BUILD_AREA_SIZE, + ) : this( + (zoneX and 0xFFFF) + .toLong() + .or((zoneZ and 0xFFFF).toLong() shl 16) + .or((widthInZones and 0xFFFF).toLong() shl 32) + .or((heightInZones and 0xFFFF).toLong() shl 48), + ) { + require(zoneX in 0..<2048) { + "ZoneX must be in range of 0..<2048: $zoneX" + } + require(zoneZ in 0..<2048) { + "ZoneZ must be in range of 0..<2048: $zoneZ" + } + require(widthInZones >= 0) { + "Width in zones cannot be negative: $widthInZones" + } + require(heightInZones >= 0) { + "Height in zones cannot be negative: $heightInZones" + } + } + + public val zoneX: Int + get() = (packed and 0xFFFF).toInt() + public val zoneZ: Int + get() = (packed ushr 16 and 0xFFFF).toInt() + public val widthInZones: Int + get() = (packed ushr 32 and 0xFFFF).toInt() + public val heightInZones: Int + get() = (packed ushr 48 and 0xFFFF).toInt() + + /** + * Localizes a specific absolute coordinate to be relative to the south-western + * corner of this build area. + * @param coordGrid the coordinate to localize. + * @return a coordinate local to the build area. + */ + internal fun localize(coordGrid: CoordGrid): CoordInBuildArea { + val (_, x, z) = coordGrid + val buildAreaX = zoneX shl 3 + val buildAreaZ = zoneZ shl 3 + val dx = x - buildAreaX + val dz = z - buildAreaZ + val maxDeltaX = this.widthInZones shl 3 + val maxDeltaZ = this.heightInZones shl 3 + check(dx in 0..= (maxDeltaX - 2)) { + return false + } + val maxDeltaZ = this.heightInZones shl 3 + return dz < (maxDeltaZ - 2) + } + + override fun toString(): String { + return "BuildArea(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "widthInZones=$widthInZones, " + + "heightInZones=$heightInZones" + + ")" + } + + public companion object { + /** + * The default build area size in zones. + */ + public const val DEFAULT_BUILD_AREA_SIZE: Int = 104 ushr 3 + + /** + * An uninitialized build area. + */ + public val INVALID: BuildArea = BuildArea(-1) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/PacketResult.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/PacketResult.kt new file mode 100644 index 000000000..05961dbfe --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/PacketResult.kt @@ -0,0 +1,317 @@ +package net.rsprot.protocol.game.outgoing.info.util + +import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoPacket +import net.rsprot.protocol.message.OutgoingGameMessage +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.fold +import kotlin.getOrElse +import kotlin.getOrThrow +import kotlin.map +import kotlin.mapCatching +import kotlin.recover +import kotlin.recoverCatching + +/** + * A non-inline variant of Kotlin's [Result] class, to make it consumable from Java. + * This file is a simple copy of what Kotlin offers, with the wrapper for failure eliminated, + * as only this project can construct instances of it. An upper bound of [OutgoingGameMessage] + * was added to ensure no one can map it into an exception and end up with faulty behavior. + * Any top-level functions are removed, only extensions on [net.rsprot.protocol.game.outgoing.info.util.PacketResult] + * are retained. + */ +public class PacketResult + @PublishedApi + internal constructor( + @PublishedApi + internal val value: Any?, + ) { + /** + * Returns `true` if this instance represents a successful outcome. + * In this case [isFailure] returns `false`. + */ + public val isSuccess: Boolean get() = value !is Throwable + + /** + * Returns `true` if this instance represents a failed outcome. + * In this case [isSuccess] returns `false`. + */ + public val isFailure: Boolean get() = value is Throwable + + // value & exception retrieval + + /** + * Returns the encapsulated value if this instance represents [success][PacketResult.isSuccess] or `null` + * if it is [failure][PacketResult.isFailure]. + * + * This function is a shorthand for `getOrElse { null }` (see [getOrElse]) or + * `fold(onSuccess = { it }, onFailure = { null })` (see [fold]). + */ + @Suppress("UNCHECKED_CAST") + public fun getOrNull(): T? = + when { + isFailure -> null + else -> value as T + } + + /** + * Returns the encapsulated [Throwable] exception if this instance represents [failure][isFailure] or `null` + * if it is [success][isSuccess]. + * + * This function is a shorthand for `fold(onSuccess = { null }, onFailure = { it })` (see [fold]). + */ + public fun exceptionOrNull(): Throwable? = + when (value) { + is Throwable -> value + else -> null + } + + /** + * Returns a string `Success(v)` if this instance represents [success][PacketResult.isSuccess] + * where `v` is a string representation of the value or a string `Failure(x)` if + * it is [failure][isFailure] where `x` is a string representation of the exception. + */ + public override fun toString(): String = + when (value) { + is Throwable -> value.toString() + else -> "Success($value)" + } + + @PublishedApi + internal companion object { + /** + * Returns an instance that encapsulates the given [value] as successful value. + */ + fun success(value: T): PacketResult { + return PacketResult(value) + } + + /** + * Returns an instance that encapsulates the given [Throwable] [exception] as failure. + */ + fun failure(exception: Throwable): PacketResult { + return PacketResult(exception) + } + } + } + +/** + * Throws exception if the result is failure. This internal function minimizes + * inlined bytecode for [getOrThrow] and makes sure that in the future we can + * add some exception-augmenting logic here (if needed). + */ +@PublishedApi +@SinceKotlin("1.3") +internal fun PacketResult<*>.throwOnFailure() { + if (value is Throwable) throw value +} + +// -- extensions --- + +/** + * Returns the encapsulated value if this instance represents [success][PacketResult.isSuccess] or throws the encapsulated [Throwable] exception + * if it is [failure][PacketResult.isFailure]. + * + * This function is a shorthand for `getOrElse { throw it }` (see [getOrElse]). + */ +public fun PacketResult.getOrThrow(): T { + throwOnFailure() + @Suppress("UNCHECKED_CAST") + return value as T +} + +/** + * Returns the encapsulated value if this instance represents [success][PacketResult.isSuccess] or the + * result of [onFailure] function for the encapsulated [Throwable] exception if it is [failure][PacketResult.isFailure]. + * + * Note, that this function rethrows any [Throwable] exception thrown by [onFailure] function. + * + * This function is a shorthand for `fold(onSuccess = { it }, onFailure = onFailure)` (see [fold]). + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.getOrElse( + onFailure: (exception: Throwable) -> R, +): R { + contract { + callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE) + } + @Suppress("UNCHECKED_CAST") + return when (val exception = exceptionOrNull()) { + null -> value as T + else -> onFailure(exception) + } +} + +/** + * Returns the encapsulated value if this instance represents [success][PacketResult.isSuccess] or the + * [defaultValue] if it is [failure][PacketResult.isFailure]. + * + * This function is a shorthand for `getOrElse { defaultValue }` (see [getOrElse]). + */ +public fun PacketResult.getOrDefault(defaultValue: R): R { + if (isFailure) return defaultValue + @Suppress("UNCHECKED_CAST") + return value as T +} + +/** + * Returns the result of [onSuccess] for the encapsulated value if this instance represents [success][PacketResult.isSuccess] + * or the result of [onFailure] function for the encapsulated [Throwable] exception if it is [failure][PacketResult.isFailure]. + * + * Note, that this function rethrows any [Throwable] exception thrown by [onSuccess] or by [onFailure] function. + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.fold( + onSuccess: (value: T) -> R, + onFailure: (exception: Throwable) -> R, +): R { + contract { + callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE) + callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE) + } + @Suppress("UNCHECKED_CAST") + return when (val exception = exceptionOrNull()) { + null -> onSuccess(value as T) + else -> onFailure(exception) + } +} + +// transformation + +/** + * Returns the encapsulated result of the given [transform] function applied to the encapsulated value + * if this instance represents [success][PacketResult.isSuccess] or the + * original encapsulated [Throwable] exception if it is [failure][PacketResult.isFailure]. + * + * Note, that this function rethrows any [Throwable] exception thrown by [transform] function. + * See [mapCatching] for an alternative that encapsulates exceptions. + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.map( + transform: (value: T) -> R, +): PacketResult { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + @Suppress("UNCHECKED_CAST") + return when { + isSuccess -> PacketResult.success(transform(value as T)) + else -> PacketResult(value) + } +} + +/** + * Returns the encapsulated result of the given [transform] function applied to the encapsulated value + * if this instance represents [success][PacketResult.isSuccess] or the + * original encapsulated [Throwable] exception if it is [failure][PacketResult.isFailure]. + * + * This function catches any [Throwable] exception thrown by [transform] function and encapsulates it as a failure. + * See [map] for an alternative that rethrows exceptions from `transform` function. + */ +public inline fun PacketResult.mapCatching( + transform: (value: T) -> R, +): PacketResult { + return when { + isSuccess -> + try { + @Suppress("UNCHECKED_CAST") + PacketResult.success(transform(value as T)) + } catch (t: Throwable) { + return PacketResult.failure(t) + } + else -> PacketResult(value) + } +} + +/** + * Returns the encapsulated result of the given [transform] function applied to the encapsulated [Throwable] exception + * if this instance represents [failure][PacketResult.isFailure] or the + * original encapsulated value if it is [success][PacketResult.isSuccess]. + * + * Note, that this function rethrows any [Throwable] exception thrown by [transform] function. + * See [recoverCatching] for an alternative that encapsulates exceptions. + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.recover( + transform: (exception: Throwable) -> R, +): PacketResult { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + return when (val exception = exceptionOrNull()) { + null -> this + else -> PacketResult.success(transform(exception)) + } +} + +/** + * Returns the encapsulated result of the given [transform] function applied to the encapsulated [Throwable] exception + * if this instance represents [failure][PacketResult.isFailure] or the + * original encapsulated value if it is [success][PacketResult.isSuccess]. + * + * This function catches any [Throwable] exception thrown by [transform] function and encapsulates it as a failure. + * See [recover] for an alternative that rethrows exceptions. + */ +public inline fun PacketResult.recoverCatching( + transform: (exception: Throwable) -> R, +): PacketResult { + return when (val exception = exceptionOrNull()) { + null -> this + else -> { + try { + PacketResult.success(transform(exception)) + } catch (t: Throwable) { + PacketResult.failure(t) + } + } + } +} + +// "peek" onto value/exception and pipe + +/** + * Performs the given [action] on the encapsulated [Throwable] exception if this instance represents [failure][PacketResult.isFailure]. + * Returns the original `Result` unchanged. + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.onFailure( + action: (exception: Throwable) -> Unit, +): PacketResult { + contract { + callsInPlace(action, InvocationKind.AT_MOST_ONCE) + } + exceptionOrNull()?.let { action(it) } + return this +} + +/** + * Performs the given [action] on the encapsulated value if this instance represents [success][PacketResult.isSuccess]. + * Returns the original `Result` unchanged. + */ +@OptIn(ExperimentalContracts::class) +public inline fun PacketResult.onSuccess(action: (value: T) -> Unit): PacketResult { + contract { + callsInPlace(action, InvocationKind.AT_MOST_ONCE) + } + @Suppress("UNCHECKED_CAST") + if (isSuccess) action(value as T) + return this +} + +/** + * Checks if the NPC info packet is empty, meaning it can be dropped by calling [safeReleaseOrThrow]. + * This is only the case if there were no high resolution NPCs to update in previous cycle, nor in + * this cycle, and the packet's length is 1 (which is just the indicator for number of NPCs to update). + * In these scenarios, OSRS seems to simply drop the packet and never send it to the client. + */ +public fun PacketResult.isEmpty(): Boolean { + return getOrNull()?.empty ?: return false +} + +/** + * Safely releases the npc info packet, or throws an exception if the result is an error instead. + */ +public fun PacketResult.safeReleaseOrThrow() { + getOrThrow().safeRelease() +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/ReferencePooledObject.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/ReferencePooledObject.kt new file mode 100644 index 000000000..99576baac --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/util/ReferencePooledObject.kt @@ -0,0 +1,44 @@ +package net.rsprot.protocol.game.outgoing.info.util + +import net.rsprot.protocol.common.client.OldSchoolClientType + +/** + * An interface used for info protocols for the purpose of re-using an object. + * This is handy in cases where the objects themselves are heavy, and deallocating and reallocating + * the object itself might become too costly, so we utilize a soft reference pool to retrieve older + * objects and re-use them in the future. + */ +public interface ReferencePooledObject { + /** + * Invoked whenever a previously pooled object is re-allocated. + * This function will be responsible for restoring state to be equivalent to newly + * instantiated object. + * @param index the index of the new element to allocate. + * @param oldSchoolClientType the client type used by the new owner. + */ + public fun onAlloc( + index: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) + + /** + * Invoked whenever a pooled object is no longer in use. + * This function is primarily used to clear out any sensitive information or potential memory leaks + * regarding byte buffers. This function should not fully reset objects, particularly primitives, + * as there is a chance a given pooled object never gets re-utilized and the garbage collector + * ends up picking it up. In such cases, it is more beneficial to do the resetting of properties + * during the [onAlloc], to ensure no work is 'wasted'. + */ + public fun onDealloc() + + /** + * Whether this reference pooled object is destroyed. + * A destroyed object will not be returned back to the pool and instead will be left off for the + * garbage collector to clean up in due time. This condition is only hit when there was some error + * thrown during the processing of a given info object. In order to mitigate potential future + * problems that might continue to stem from re-using this object, we discard it altogether. + * @return whether the info object is destroyed and should not be returned to the pool. + */ + public fun isDestroyed(): Boolean +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/DefaultProtocolWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/DefaultProtocolWorker.kt new file mode 100644 index 000000000..1286ff705 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/DefaultProtocolWorker.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.info.worker + +import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService +import java.util.concurrent.ForkJoinPool + +/** + * The default protocol worker, utilizing the calling thread + * if there are less than [asynchronousThreshold] callables to execute. + * Otherwise, utilizes the [executorService] to process the callables in parallel. + */ +public class DefaultProtocolWorker( + private val asynchronousThreshold: Int, + private val executorService: ExecutorService, +) : ProtocolWorker { + /** + * A default implementation that switches to parallel processing using [ForkJoinPool] + * if there are at least `coreCount * 4` callables to execute. + * Otherwise, utilizes the calling thread. + */ + public constructor() : this( + Runtime.getRuntime().availableProcessors() * 4, + ForkJoinPool.commonPool(), + ) + + override fun execute(callables: List>) { + if (callables.size < asynchronousThreshold) { + for (callable in callables) { + callable.call() + } + } else { + executorService.invokeAll(callables) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ForkJoinMultiThreadProtocolWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ForkJoinMultiThreadProtocolWorker.kt new file mode 100644 index 000000000..e6d4179a0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ForkJoinMultiThreadProtocolWorker.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.outgoing.info.worker + +import java.util.concurrent.Callable +import java.util.concurrent.ForkJoinPool + +/** + * A simple single-threaded info worker, executing all the callables using [ForkJoinPool]. + * The pool will be used even if there are very few callables to execute. + */ +public class ForkJoinMultiThreadProtocolWorker : ProtocolWorker { + override fun execute(callables: List>) { + ForkJoinPool.commonPool().invokeAll(callables) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ProtocolWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ProtocolWorker.kt new file mode 100644 index 000000000..779eef945 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/ProtocolWorker.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.game.outgoing.info.worker + +import java.util.concurrent.Callable + +/** + * Provides an API to processing info protocols. + */ +public interface ProtocolWorker { + /** + * Executes the [callables] collection as defined by the given worker's behavior. + * The callables may be executed asynchronously and are guaranteed to be thread-safe. + * It should be noted that _all_ the callables must be called upon, or the protocol breaks. + * + * @param callables the list of callables that must be executed. + */ + public fun execute(callables: List>) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/SingleThreadProtocolWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/SingleThreadProtocolWorker.kt new file mode 100644 index 000000000..32b96331c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worker/SingleThreadProtocolWorker.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.game.outgoing.info.worker + +import java.util.concurrent.Callable + +/** + * A simple single-threaded info worker, executing all the callables in order on the calling thread. + */ +public class SingleThreadProtocolWorker : ProtocolWorker { + override fun execute(callables: List>) { + for (callable in callables) { + callable.call() + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatar.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatar.kt new file mode 100644 index 000000000..488831653 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatar.kt @@ -0,0 +1,211 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.extensions.p1 +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.game.outgoing.info.util.Avatar +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.CoordFine +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +/** + * A world entity avatar represents a dynamic world entity as a single unit. + + * @property allocator the byte buffer allocator to be used for the high resolution + * movement buffer of this world entity. + * @property zoneIndexStorage the storage responsible for tracking world entities across + * zones. + * @property index the index of this world entity. + * @property sizeX the width of the world entity in zones. + * @property sizeZ the height of the world entity in zones. + * @property southWestZoneX the south-western zone x of the worldentity instance. + * @property southWestZoneZ the south-western zone z of the worldentity instance. + * @property minLevel the minimum level of the instance being built. + * @property maxLevel the maximum level of the instance being built, inclusive. + * @property id the cache config id + * @property ownerIndex the index of the owner of this avatar. + * If the index is > 0, a player by that index will own this avatar. + * If the index is < 0, a NPC will own the avatar. + * @property currentCoordFine the coordinate that this world entity is being rendered at. + * @property angle the current angle of this world entity. + * @property lastCoordFine the last known coordinate of the world entity by the client. + * @property highResolutionBuffer the buffer which contains the pre-computed high resolution + * movement of this avatar. + */ +public class WorldEntityAvatar( + internal val allocator: ByteBufAllocator, + internal val zoneIndexStorage: ZoneIndexStorage, + internal var index: Int, + internal var sizeX: Int, + internal var sizeZ: Int, + internal var southWestZoneX: Int, + internal var southWestZoneZ: Int, + internal var minLevel: Int, + internal var maxLevel: Int, + internal var id: Int, + internal var ownerIndex: Int, + internal var projectedLevel: Int, + internal var activeLevel: Int, + internal var currentCoordFine: CoordFine = CoordFine.INVALID, + internal var angle: Int, + public val extendedInfo: WorldEntityAvatarExtendedInfo, +) : Avatar { + internal var teleport: Boolean = false + internal var lastAngle: Int = angle + internal var lastCoordFine: CoordFine = currentCoordFine + internal var specific: Boolean = false + + internal var highResolutionBuffer: ByteBuf? = null + + internal val currentCoordGrid: CoordGrid + get() = currentCoordFine.toCoordGrid(this.projectedLevel) + + /** + * The [WorldEntityProtocol.cycleCount] when this avatar was allocated. + * We use this to determine whether to perform a re-synchronization of a worldentity, + * which can happen when a worldentity is deallocated and reallocated on the same cycle, + * which could result in other clients not seeing any change take place. While rare, + * this possibility exists, and it could result in some rather odd bugs. + */ + internal var allocateCycle: Int = WorldEntityProtocol.cycleCount + + /** + * Sets this world entity as specific, meaning it will only render to the players + * that own it ([ownerIndex] matches their index), and any players who currently + * reside on the world entity. Anyone else will not see it. + */ + public fun setSpecific(specific: Boolean) { + checkCommunicationThread() + this.specific = specific + } + + /** + * Gets the priority of this world entity towards the player with the index [playerIndex]. + * If the player by the index of [playerIndex] owns this world entity, they receive the highest + * priority. + * If a NPC owns this world entity, they receive the second-highest priority. NPC-owned entities + * are prioritized over other players, as players need to always be able to see ships like + * the ones starting/finishing the barracuda trials. + * If another player owns the world entity, the lowest priority is provided. + */ + internal fun priorityTowards(playerIndex: Int): WorldEntityPriority { + val ownerIndex = this.ownerIndex + if (ownerIndex == playerIndex) { + return WorldEntityPriority.LOCAL_PLAYER + } + if (ownerIndex < 0) { + return WorldEntityPriority.NPC + } + return WorldEntityPriority.OTHER_PLAYER + } + + /** + * Precomputes the high resolution buffer of this world entity. + */ + internal fun precompute() { + extendedInfo.precompute() + if (this.currentCoordFine == this.lastCoordFine && this.angle == this.lastAngle) { + val buffer = allocator.buffer(1, 1) + this.highResolutionBuffer = buffer + // Opcode 1 indicates no change + buffer.p1(1) + return + } + val buffer = + allocator + .buffer(MAX_HIGH_RES_BUF_SIZE, MAX_HIGH_RES_BUF_SIZE) + .toJagByteBuf() + this.highResolutionBuffer = buffer.buffer + + // Opcode 3 indicates teleport, 2 indicates smooth movement + if (this.teleport) { + buffer.p1(3) + } else { + buffer.p1(2) + } + val dx = currentCoordFine.x - lastCoordFine.x + val dy = currentCoordFine.y - lastCoordFine.y + val dz = currentCoordFine.z - lastCoordFine.z + val dAngle = angle - lastAngle + buffer.encodeAngledCoordFine(dx, dy, dz, dAngle) + } + + /** + * Updates the current coordinate of this world entity. + * @param level the current absolute level of this world entity. + * @param fineX the absolute fine x coordinate of this world entity. This coordinate is effectively + * absolute coordinate * 128. + * @param fineZ the absolute fine z coordinate of this world entity. This coordinate is effectively + * absolute coordinate * 128. + * @param teleport whether to jump the worldentity to the desired coordinate. + */ + @Throws(IllegalArgumentException::class) + public fun updateCoord( + level: Int, + fineX: Int, + fineZ: Int, + teleport: Boolean, + ) { + @Suppress("DEPRECATION") + updateCoord(level, fineX, 0, fineZ, teleport) + } + + /** + * Updates the current coordinate of this world entity. + * @param level the current absolute level of this world entity. + * @param fineX the absolute fine x coordinate of this world entity. This coordinate is effectively + * absolute coordinate * 128. + * @param fineY the fine y coordinate (or the height) of this world entity. This value + * should be in range of 0..1023. Note that as of revision 226, this property is overwritten by the + * ground height and has no impact on the perceived height of the world entity. + * @param fineZ the absolute fine z coordinate of this world entity. This coordinate is effectively + * absolute coordinate * 128. + * @param teleport whether to jump the worldentity to the desired coordinate. + */ + @Throws(IllegalArgumentException::class) + @Deprecated( + "Deprecated as fineY is no longer utilized by the client.", + replaceWith = ReplaceWith("updateCoord(level, fineX, fineZ, teleport)"), + ) + public fun updateCoord( + level: Int, + fineX: Int, + fineY: Int, + fineZ: Int, + teleport: Boolean, + ) { + checkCommunicationThread() + val coordFine = CoordFine(fineX, fineY, fineZ) + val coordGrid = coordFine.toCoordGrid(level) + this.zoneIndexStorage.move(this.index, currentCoordGrid, coordGrid) + this.currentCoordFine = coordFine + this.projectedLevel = level + this.teleport = teleport + } + + /** + * Updates the current angle of this world entity. + * It should be noted that the client is only made to rotate by a maximum of 22.5 degrees (128/2048 units) + * per game cycle, so it may take multiple seconds for it to finish the turn. + */ + public fun updateAngle(angle: Int) { + checkCommunicationThread() + this.angle = angle + } + + override fun postUpdate() { + this.lastCoordFine = this.currentCoordFine + this.highResolutionBuffer?.release() + this.teleport = false + this.lastAngle = this.angle + } + + private companion object { + /** + * The maximum buffer size for the high resolution precomputed buffer. + */ + private const val MAX_HIGH_RES_BUF_SIZE: Int = 18 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExceptionHandler.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExceptionHandler.kt new file mode 100644 index 000000000..93447eb60 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExceptionHandler.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import java.lang.Exception + +public fun interface WorldEntityAvatarExceptionHandler { + /** + * This function is triggered whenever there's an exception caught during world entity + * avatar processing. + * @param index the index of the world entity that had an exception during its processing. + * @param exception the exception that was caught during a world entity's avatar processing + */ + public fun exceptionCaught( + index: Int, + exception: Exception, + ) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfo.kt new file mode 100644 index 000000000..f71dbf5ea --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfo.kt @@ -0,0 +1,228 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.precompute +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps +import net.rsprot.protocol.internal.game.outgoing.info.worldentityinfo.encoder.WorldEntityExtendedInfoEncoders + +public typealias WorldEntityAvatarExtendedInfoWriter = + AvatarExtendedInfoWriter + +/** + * World entity avatar extended info is a data structure used to keep track of all the extended info + * properties of the given avatar. + */ +public class WorldEntityAvatarExtendedInfo( + private var avatarIndex: Int, + extendedInfoWriters: List, + private val allocator: ByteBufAllocator, + private val huffmanCodec: HuffmanCodecProvider, +) { + /** + * The extended info blocks enabled on this World Entity in a given cycle. + */ + internal var flags: Int = 0 + + /** + * Extended info blocks used to transmit changes to the client, + * wrapped in its own class as we must pass this onto the client-specific + * implementations. + */ + private val blocks: WorldEntityAvatarExtendedInfoBlocks = WorldEntityAvatarExtendedInfoBlocks(extendedInfoWriters) + + /** + * The client-specific extended info writers, indexed by the respective [OldSchoolClientType]'s id. + * All clients in use must be registered, or an exception will occur during world entity info encoding. + */ + private val writers: Array = + buildClientWriterArray(extendedInfoWriters) + + /** + * Sets the sequence for this avatar to play. + * @param id the id of the sequence to play, or -1 to stop playing current sequence. + * @param delay the delay in client cycles (20ms/cc) until the avatar starts playing this sequence. + */ + public fun setSequence( + id: Int, + delay: Int, + ) { + checkCommunicationThread() + verify { + require(id == -1 || id in UNSIGNED_SHORT_RANGE) { + "Unexpected sequence id: $id, expected value -1 or in range $UNSIGNED_SHORT_RANGE" + } + require(delay in UNSIGNED_BYTE_RANGE) { + "Unexpected sequence delay: $delay, expected range: $UNSIGNED_BYTE_RANGE" + } + } + blocks.sequence.id = id.toUShort() + blocks.sequence.delay = delay.toUShort() + flags = flags or SEQUENCE + } + + /** + * Sets the visible ops flag of this World Entity to the provided value. + * @param flag the bit flag to set. Only the 5 lowest bits are used, + * and an enabled bit implies the option at that index should render. + * Note that this extended info block is not transient and will be transmitted to + * future players as well. + * + * Use [net.rsprot.protocol.game.outgoing.util.OpFlags] class to build the flag. + */ + public fun setVisibleOps(flag: Byte) { + checkCommunicationThread() + blocks.visibleOps.ops = flag.toUByte() + flags = flags or VISIBLE_OPS + } + + /** + * Clears any transient information and resets the flag to zero at the end of the cycle. + */ + internal fun postUpdate() { + clearTransientExtendedInformation() + flags = 0 + } + + /** + * Resets all the properties of this extended info object, making it ready for use + * by another avatar. + */ + internal fun reset() { + flags = 0 + blocks.sequence.clear() + blocks.visibleOps.clear() + } + + /** + * Checks if the avatar has any extended info flagged. + * @return whether any extended info flags are set. + */ + internal fun hasExtendedInfo(): Boolean { + return this.flags != 0 + } + + /** + * Pre-computes all the buffers for this avatar. + * Pre-computation is done, so we don't have to calculate these extended info blocks + * for every avatar that observes us. Instead, we can do more performance-efficient + * operations of native memory copying to get the latest extended info blocks. + */ + internal fun precompute() { + precomputeCached() + if (flags and SEQUENCE != 0) { + blocks.sequence.precompute(allocator, huffmanCodec) + } + } + + /** + * Precomputes the extended info blocks which are cached and potentially transmitted + * to any players who newly observe this world entity. The full list of extended info blocks + * which must be placed in here is seen in [getLowToHighResChangeExtendedInfoFlags]. + * Every condition there must be among this function, else it is possible to run into + * scenarios where a block isn't computed but is required in the future. + */ + internal fun precomputeCached() { + if (flags and VISIBLE_OPS != 0) { + blocks.visibleOps.precompute(allocator, huffmanCodec) + } + } + + /** + * Writes the extended info block of this avatar for the given observer. + * @param oldSchoolClientType the client that the observer is using. + * @param buffer the buffer into which the extended info block should be written. + * @param observerIndex index of the player avatar that is observing us. + */ + internal fun pExtendedInfo( + oldSchoolClientType: OldSchoolClientType, + buffer: JagByteBuf, + observerIndex: Int, + extraFlag: Int, + flagWriteIndex: Int, + ) { + val flag = this.flags or extraFlag + val writer = + requireNotNull(writers[oldSchoolClientType.id]) { + "Extended info writer missing for client $oldSchoolClientType" + } + + writer.pExtendedInfo( + buffer, + avatarIndex, + observerIndex, + flag, + blocks, + flagWriteIndex, + ) + } + + /** + * Gets the set of extended info blocks that were previously set but also + * need to be transmitted to any new users. + * @return the bit flag of all the non-transient extended info blocks that were previously flagged. + */ + internal fun getLowToHighResChangeExtendedInfoFlags(): Int { + var flag = 0 + if (this.flags and VISIBLE_OPS == 0 && + blocks.visibleOps.ops != VisibleOps.DEFAULT_OPS + ) { + flag = flag or VISIBLE_OPS + } + return flag + } + + /** + * Clears any transient extended info that was flagged in this cycle. + */ + private fun clearTransientExtendedInformation() { + val flags = this.flags + if (flags == 0) return + if (flags and SEQUENCE != 0) { + blocks.sequence.clear() + } + } + + public companion object { + private val UNSIGNED_BYTE_RANGE: IntRange = UByte.MIN_VALUE.toInt()..UByte.MAX_VALUE.toInt() + private val UNSIGNED_SHORT_RANGE: IntRange = UShort.MIN_VALUE.toInt()..UShort.MAX_VALUE.toInt() + + public const val SEQUENCE: Int = 0x1 + public const val VISIBLE_OPS: Int = 0x2 + + /** + * Executes the [block] if input verification is enabled, + * otherwise does nothing. Verification should be enabled for + * development environments, to catch problems mid-development. + * In production, or during benchmarking, verification should be disabled, + * as there is still some overhead to running verifications. + */ + private inline fun verify(crossinline block: () -> Unit) { + if (RSProtFlags.extendedInfoInputVerification) { + block() + } + } + + /** + * Builds an extended info writer array indexed by provided client types. + * All client types which are utilized must be registered to avoid runtime errors. + */ + private fun buildClientWriterArray( + extendedInfoWriters: List, + ): Array { + val array = + arrayOfNulls( + OldSchoolClientType.COUNT, + ) + for (writer in extendedInfoWriters) { + array[writer.oldSchoolClientType.id] = writer + } + return array + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfoBlocks.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfoBlocks.kt new file mode 100644 index 000000000..da16c226e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarExtendedInfoBlocks.kt @@ -0,0 +1,49 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.AvatarExtendedInfoWriter +import net.rsprot.protocol.internal.client.ClientTypeMap +import net.rsprot.protocol.internal.game.outgoing.info.ExtendedInfo +import net.rsprot.protocol.internal.game.outgoing.info.encoder.ExtendedInfoEncoder +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.Sequence +import net.rsprot.protocol.internal.game.outgoing.info.shared.extendedinfo.VisibleOps +import net.rsprot.protocol.internal.game.outgoing.info.worldentityinfo.encoder.WorldEntityExtendedInfoEncoders + +private typealias WEEnc = WorldEntityExtendedInfoEncoders +private typealias WorldEntityExtendedInfoWriters = + List> + +/** + * A data structure to bring all the extended info blocks together, + * so the information can be passed onto various client-specific encoders. + * @param writers the list of client-specific writers. + * The writers must be client-specific too, not just encoders, as + * the order in which the extended info blocks get written must follow + * the exact order described by the client. + */ +public class WorldEntityAvatarExtendedInfoBlocks( + writers: WorldEntityExtendedInfoWriters, +) { + public val sequence: Sequence = Sequence(encoders(writers, WEEnc::sequence)) + public val visibleOps: VisibleOps = VisibleOps(encoders(writers, WEEnc::visibleOps)) + + private companion object { + /** + * Builds a client-specific map of encoders for a specific extended info block, + * keyed by [OldSchoolClientType.id]. + * If a client hasn't been registered, the encoder at that index will be null. + * @param allEncoders all the client-specific extended info writers for the given type. + * @param selector a higher order function to retrieve a specific extended info block from + * the full structure of all the extended info blocks. + * @return a map of client-specific encoders of the given extended info block, + * keyed by [OldSchoolClientType.id]. + */ + private inline fun , reified E : ExtendedInfoEncoder> encoders( + allEncoders: WorldEntityExtendedInfoWriters, + selector: (WorldEntityExtendedInfoEncoders) -> E, + ): ClientTypeMap = + ClientTypeMap.ofType(allEncoders, OldSchoolClientType.COUNT) { + it.encoders.oldSchoolClientType to selector(it.encoders) + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarFactory.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarFactory.kt new file mode 100644 index 000000000..bc5fd4b02 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarFactory.kt @@ -0,0 +1,259 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage + +/** + * An avatar factory for world entities. + * This class will be responsible for allocating and releasing world entity avatars, + * allowing them to be pooled and re-used, if needed. + * @property avatarRepository the repository keeping track of existing and past world + * entity avatars. + * @property huffmanCodec the huffman codec is used to compress chat extended info. + * While NPCs do not currently have any such extended info blocks, the interface requires + * it be passed in, so we must still provide it. + */ +@Suppress("DuplicatedCode") +public class WorldEntityAvatarFactory( + allocator: ByteBufAllocator, + zoneIndexStorage: ZoneIndexStorage, + extendedInfoWriter: List, + huffmanCodec: HuffmanCodecProvider, +) { + internal val avatarRepository: WorldEntityAvatarRepository = + WorldEntityAvatarRepository( + allocator, + zoneIndexStorage, + extendedInfoWriter, + huffmanCodec, + ) + + /** + * Allocates a new world entity with the provided arguments. + * @param index the index of the world entity + * @param id the id of the world entity config + * @param ownerIndex the index of the owner of this avatar. + * If the index is > 0, a player by that index will own this avatar. + * If the index is < 0, a NPC will own the avatar. + * @param sizeX the width of the world entity in zones (8 tiles/zone) + * @param sizeZ the height of the world entity in zones (8 tiles/zone) + * @param southWestZoneX the southwestern zone x of the worldentity instance + * @param southWestZoneZ the southwestern zone z of the worldentity instance + * @param minLevel the minimum level of the instance. + * @param maxLevel the maximum level of the instance, inclusive. + * @param fineX the absolute fine x coordinate of the avatar. This can be calculated + * by doing x * 128 with absolute coord grid values. + * @param fineZ the absolute fine x coordinate of the avatar. This can be calculated + * by doing z * 128 with absolute coord grid values. + * @param projectedLevel the projected root level of the world entity, where it renders. + * @param activeLevel the level on which the entities in the world entity (instance) appear. + * @param angle the angle to face + * @return either a new world entity avatar, or a pooled one that has been + * updated to contain the provided params. + */ + public fun alloc( + index: Int, + id: Int, + ownerIndex: Int, + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + fineX: Int, + fineZ: Int, + projectedLevel: Int, + activeLevel: Int, + angle: Int, + ): WorldEntityAvatar { + checkCommunicationThread() + require(index in 0..4095) { + "World entity index cannot be outside of 0..4095" + } + require(id in 0..65535) { + "World entity id must be in range of 0..65535" + } + require(sizeX in 0..255) { + "Size x cannot be outside of 0..255 range" + } + require(sizeZ in 0..255) { + "Size z cannot be outside of 0..255 range" + } + require(southWestZoneX in 0..<2048) { + "South west zone X must be in range of 0..<2048" + } + require(southWestZoneZ in 0..<2048) { + "South west zone Z must be in range of 0..<2048" + } + require(minLevel in 0..<4) { + "Min level must be in range of 0..<4" + } + require(maxLevel in 0..<4) { + "Max level must be in range of 0..<4" + } + require(minLevel <= maxLevel) { + "Min level cannot be higher than max level: $minLevel, $maxLevel" + } + require(projectedLevel in 0..3) { + "Projected level cannot be outside of 0..3 range" + } + require(activeLevel in 0..3) { + "Active level cannot be outside of 0..3 range" + } + require(fineX in 0..2_097_151) { + "Fine X coordinate cannot be outside of 0..2_097_151 range" + } + require(fineX in 0..2_097_151) { + "Fine Z coordinate cannot be outside of 0..2_097_151 range" + } + require(angle in 0..2047) { + "Angle must be in range of 0..2047" + } + return avatarRepository.getOrAlloc( + index, + id, + ownerIndex, + sizeX, + sizeZ, + southWestZoneX, + southWestZoneZ, + minLevel, + maxLevel, + fineX, + fineZ, + projectedLevel, + activeLevel, + angle, + ) + } + + /** + * Allocates a new world entity with the provided arguments. + * @param index the index of the world entity + * @param id the id of the world entity config + * @param ownerIndex the index of the owner of this avatar. + * If the index is > 0, a player by that index will own this avatar. + * If the index is < 0, a NPC will own the avatar. + * @param sizeX the width of the world entity in zones (8 tiles/zone) + * @param sizeZ the height of the world entity in zones (8 tiles/zone) + * @param southWestZoneX the southwestern zone x of the worldentity instance + * @param southWestZoneZ the southwestern zone z of the worldentity instance + * @param minLevel the minimum level of the instance. + * @param maxLevel the maximum level of the instance, inclusive. + * @param fineX the absolute fine x coordinate of the avatar. This can be calculated + * by doing x * 128 with absolute coord grid values. + * @param fineY the fine y coordinate (height) of the avatar. Note that as of revision 226, + * this property is overwritten by the ground height and has no impact on the perceived + * height of the world entity. + * @param fineZ the absolute fine x coordinate of the avatar. This can be calculated + * by doing z * 128 with absolute coord grid values. + * @param projectedLevel the projected root level of the world entity, where it renders. + * @param activeLevel the level on which the entities in the world entity (instance) appear. + * @param angle the angle to face + * @return either a new world entity avatar, or a pooled one that has been + * updated to contain the provided params. + */ + @Deprecated( + "Deprecated as fineY is no longer utilized by the client.", + replaceWith = + ReplaceWith( + "alloc(index, id, ownerIndex, " + + "sizeX, sizeZ, fineX, fineZ, " + + "projectedLevel, activeLevel, angle)", + ), + ) + public fun alloc( + index: Int, + id: Int, + ownerIndex: Int, + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + fineX: Int, + fineY: Int, + fineZ: Int, + projectedLevel: Int, + activeLevel: Int, + angle: Int, + ): WorldEntityAvatar { + checkCommunicationThread() + require(index in 0..4095) { + "World entity index cannot be outside of 0..4095" + } + require(id in 0..65535) { + "World entity id must be in range of 0..65535" + } + require(sizeX in 0..15) { + "Size x cannot be outside of 0..15 range" + } + require(sizeZ in 0..15) { + "Size z cannot be outside of 0..15 range" + } + require(southWestZoneX in 0..<2048) { + "South west zone X must be in range of 0..<2048" + } + require(southWestZoneZ in 0..<2048) { + "South west zone Z must be in range of 0..<2048" + } + require(minLevel in 0..<4) { + "Min level must be in range of 0..<4" + } + require(maxLevel in 0..<4) { + "Max level must be in range of 0..<4" + } + require(minLevel <= maxLevel) { + "Min level cannot be higher than max level: $minLevel, $maxLevel" + } + require(projectedLevel in 0..3) { + "Projected level cannot be outside of 0..3 range" + } + require(activeLevel in 0..3) { + "Active level cannot be outside of 0..3 range" + } + require(fineX in 0..2_097_151) { + "Fine X coordinate cannot be outside of 0..2_097_151 range" + } + require(fineY in 0..1023) { + "Fine Y coordinate cannot be outside of 0..1023 range" + } + require(fineX in 0..2_097_151) { + "Fine Z coordinate cannot be outside of 0..2_097_151 range" + } + require(angle in 0..2047) { + "Angle must be in range of 0..2047" + } + @Suppress("DEPRECATION") + return avatarRepository.getOrAlloc( + index, + id, + ownerIndex, + sizeX, + sizeZ, + southWestZoneX, + southWestZoneZ, + minLevel, + maxLevel, + fineX, + fineY, + fineZ, + projectedLevel, + activeLevel, + angle, + ) + } + + /** + * Releases a world entity avatar back into the pool, allowing it to be re-used in the future. + * @param avatar the world entity avatar to be released. + */ + public fun release(avatar: WorldEntityAvatar) { + checkCommunicationThread() + avatarRepository.release(avatar) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarRepository.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarRepository.kt new file mode 100644 index 000000000..e37a836e0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityAvatarRepository.kt @@ -0,0 +1,303 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import io.netty.buffer.ByteBufAllocator +import net.rsprot.compression.provider.HuffmanCodecProvider +import net.rsprot.protocol.internal.game.outgoing.info.CoordFine +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import java.lang.ref.ReferenceQueue +import java.lang.ref.SoftReference + +/** + * An avatar repository for world entities, keeping track of every current avatar, + * as well as any avatars that were previously used but now released. + * @property allocator an allocator for the world entity avatars, to be used for + * precomputed high resolution blocks. + * @property zoneIndexStorage the zone index storage is responsible for tracking + * world entities across zones. + * @property extendedInfoWriter the client-specific extended info writers for World Entity information. + * @property elements the array of existing world entity avatars, currently in use. + * @property queue the soft reference queue of world avatars that were previously in use. + * As a soft reference queue, it will hold on-to the unused references until the JVM + * absolutely needs the memory - before that, these can be reused, making it a perfect + * use case for the pooling mechanism. + */ +public class WorldEntityAvatarRepository internal constructor( + private val allocator: ByteBufAllocator, + private val zoneIndexStorage: ZoneIndexStorage, + private val extendedInfoWriter: List, + private val huffmanCodec: HuffmanCodecProvider, + private val map: WorldEntityMap = WorldEntityMap(), +) { + private val elements: Array = arrayOfNulls(AVATAR_CAPACITY) + private val queue: ReferenceQueue = ReferenceQueue() + + /** + * Looks up a world entity index based on the [coordGrid] by seeing whichever worldentity owns + * the zone at which the coordgrid exists. If no worldentity exists there, -1 is returned. + * @return the world entity index that owns the coordgrid, or -1. + */ + public fun getByCoordGrid(coordGrid: CoordGrid): Int { + return map.get(coordGrid) + } + + /** + * Gets a world entity at the provided [idx], or null if it doesn't exist. + * @throws ArrayIndexOutOfBoundsException if the [idx] is < 0, or >= [AVATAR_CAPACITY]. + */ + @Throws(ArrayIndexOutOfBoundsException::class) + public fun getOrNull(idx: Int): WorldEntityAvatar? = elements[idx] + + /** + * Gets an existing world entity avatar from the queue if one is ready, or constructs + * a new avatar if not. + * @param index the index of the world entity + * @param id the id of the world entity + * @param ownerIndex the index of the owner of this avatar. + * If the index is > 0, a player by that index will own this avatar. + * If the index is < 0, a NPC will own the avatar. + * @param sizeX the width of the world entity in zones (8 tiles/zone) + * @param sizeZ the height of the world entity in zones (8 tiles/zone) + * @param southWestZoneX the southwestern zone x of the worldentity instance + * @param southWestZoneZ the southwestern zone z of the worldentity instance + * @param minLevel the minimum level of the instance. + * @param maxLevel the maximum level of the instance, inclusive. + * @param fineX the absolute fine x coordinate of the avatar. This can be calculated + * by doing x * 128 with absolute coord grid values. + * @param fineZ the absolute fine x coordinate of the avatar. This can be calculated + * by doing z * 128 with absolute coord grid values. + * @param projectedLevel the projected root level of the world entity, where it renders. + * @param activeLevel the level on which the entities in the world entity (instance) appear. + * @param angle the angle to face + * @return either a new world entity avatar, or a pooled one that has been + * updated to contain the provided params. + */ + public fun getOrAlloc( + index: Int, + id: Int, + ownerIndex: Int, + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + fineX: Int, + fineZ: Int, + projectedLevel: Int, + activeLevel: Int, + angle: Int, + ): WorldEntityAvatar { + @Suppress("DEPRECATION") + return getOrAlloc( + index, + id, + ownerIndex, + sizeX, + sizeZ, + southWestZoneX, + southWestZoneZ, + minLevel, + maxLevel, + fineX, + 0, + fineZ, + projectedLevel, + activeLevel, + angle, + ) + } + + /** + * Gets an existing world entity avatar from the queue if one is ready, or constructs + * a new avatar if not. + * @param index the index of the world entity + * @param id the id of the world entity + * @param ownerIndex the index of the owner of this avatar. + * If the index is > 0, a player by that index will own this avatar. + * If the index is < 0, a NPC will own the avatar. + * @param sizeX the width of the world entity in zones (8 tiles/zone) + * @param sizeZ the height of the world entity in zones (8 tiles/zone) + * @param southWestZoneX the southwestern zone x of the worldentity instance + * @param southWestZoneZ the southwestern zone z of the worldentity instance + * @param minLevel the minimum level of the instance. + * @param maxLevel the maximum level of the instance, inclusive. + * @param fineX the absolute fine x coordinate of the avatar. This can be calculated + * by doing x * 128 with absolute coord grid values. + * @param fineY the fine y coordinate (height) of the avatar. Note that as of revision 226, + * this property is overwritten by the ground height and has no impact on the perceived + * height of the world entity. + * @param fineZ the absolute fine x coordinate of the avatar. This can be calculated + * by doing z * 128 with absolute coord grid values. + * @param projectedLevel the projected root level of the world entity, where it renders. + * @param activeLevel the level on which the entities in the world entity (instance) appear. + * @param angle the angle to face + * @return either a new world entity avatar, or a pooled one that has been + * updated to contain the provided params. + */ + @Deprecated( + "Deprecated as fineY is no longer utilized by the client.", + replaceWith = + ReplaceWith( + "getOrAlloc(index, id, ownerIndex, " + + "sizeX, sizeZ, fineX, fineZ, " + + "projectedLevel, activeLevel, angle)", + ), + ) + public fun getOrAlloc( + index: Int, + id: Int, + ownerIndex: Int, + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + fineX: Int, + fineY: Int, + fineZ: Int, + projectedLevel: Int, + activeLevel: Int, + angle: Int, + ): WorldEntityAvatar { + val old = this.elements[index] + require(old == null) { + "WorldEntity avatar with index $index is already allocated: $old" + } + storeInMap( + sizeX, + sizeZ, + southWestZoneX, + southWestZoneZ, + minLevel, + maxLevel, + index, + ) + val existing = queue.poll()?.get() + if (existing != null) { + existing.index = index + existing.sizeX = sizeX + existing.sizeZ = sizeZ + existing.currentCoordFine = CoordFine(fineX, fineY, fineZ) + existing.lastCoordFine = existing.currentCoordFine + existing.angle = angle + existing.lastAngle = angle + existing.teleport = false + existing.allocateCycle = WorldEntityProtocol.cycleCount + existing.id = id + existing.ownerIndex = ownerIndex + existing.projectedLevel = projectedLevel + existing.activeLevel = activeLevel + existing.southWestZoneX = southWestZoneX + existing.southWestZoneZ = southWestZoneZ + existing.minLevel = minLevel + existing.maxLevel = maxLevel + existing.specific = false + zoneIndexStorage.add(index, existing.currentCoordGrid) + elements[index] = existing + return existing + } + val extendedInfo = + WorldEntityAvatarExtendedInfo( + index, + extendedInfoWriter, + allocator, + huffmanCodec, + ) + val avatar = + WorldEntityAvatar( + allocator, + zoneIndexStorage, + index, + sizeX, + sizeZ, + southWestZoneX, + southWestZoneZ, + minLevel, + maxLevel, + id, + ownerIndex, + projectedLevel, + activeLevel, + CoordFine(fineX, fineY, fineZ), + angle, + extendedInfo, + ) + zoneIndexStorage.add(index, avatar.currentCoordGrid) + elements[index] = avatar + return avatar + } + + private fun storeInMap( + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + index: Int, + ) { + map.put( + southWestZoneX = southWestZoneX, + southWestZoneZ = southWestZoneZ, + widthInZones = sizeX, + lengthInZones = sizeZ, + minLevel = minLevel, + maxLevel = maxLevel, + index = index, + ) + } + + private fun removeFromMap( + sizeX: Int, + sizeZ: Int, + southWestZoneX: Int, + southWestZoneZ: Int, + minLevel: Int, + maxLevel: Int, + index: Int, + ) { + map.remove( + southWestZoneX = southWestZoneX, + southWestZoneZ = southWestZoneZ, + widthInZones = sizeX, + lengthInZones = sizeZ, + minLevel = minLevel, + maxLevel = maxLevel, + expectedIndex = index, + ) + } + + /** + * Releases avatar back into the pool for it to be used later in the future, if possible. + * @param avatar the avatar to release. + */ + public fun release(avatar: WorldEntityAvatar) { + val index = avatar.index + // Ensure the avatars share the same reference! + require(this.elements[index] === avatar) { + "Attempting to release an invalid WorldEntity avatar: $avatar, ${this.elements[index]}" + } + removeFromMap( + avatar.sizeX, + avatar.sizeZ, + avatar.southWestZoneX, + avatar.southWestZoneZ, + avatar.minLevel, + avatar.maxLevel, + index, + ) + zoneIndexStorage.remove(index, avatar.currentCoordGrid) + this.elements[index] = null + val reference = SoftReference(avatar, queue) + reference.enqueue() + } + + internal companion object { + /** + * The maximum number of world entity avatars. + */ + internal const val AVATAR_CAPACITY = 2048 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt new file mode 100644 index 000000000..b665df5c9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -0,0 +1,775 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.ByteBufRecycler +import net.rsprot.protocol.game.outgoing.info.exceptions.InfoProcessException +import net.rsprot.protocol.game.outgoing.info.util.BuildArea +import net.rsprot.protocol.game.outgoing.info.util.PacketResult +import net.rsprot.protocol.game.outgoing.info.util.ReferencePooledObject +import net.rsprot.protocol.internal.checkCommunicationThread +import net.rsprot.protocol.internal.game.outgoing.info.CoordFine +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage +import java.util.Collections + +/** + * The world entity info class tracks everything about the world entities that + * are near this player. + * @property localIndex the index of the local player that owns this world entity info. + * @property allocator the byte buffer allocator used to build the buffer for the packet. + * @property oldSchoolClientType the client type on which the player has logged in. + * @property avatarRepository the avatar repository keeping track of every known + * world entity in the root world. + * @property zoneIndexStorage the storage responsible for tracking the zones in which + * the world entities currently lie. + * @property renderDistance the render distance in tiles, effectively how far to render + * world entities from the local player (or the camera pov) + * @property coordInRootWorld the current real coordinate of the local player. + * @property rootBuildArea the current build area of the player, this is the root base + * map that's being rendered to the player. + * @property highResolutionIndicesCount the number of high resolution world entity avatars. + * @property highResolutionIndices the indices of all the high resolution avatars currently + * being tracked. + * @property temporaryHighResolutionIndices a temporary array of high resolution avatar indices, + * allowing for more efficient defragmentation of the indices when indices get removed + * from the middle of the array. + * @property allWorldEntities the indices of all the world entities currently in high resolution, + * provided in a list format as the server must know everything currently rendered, so it can + * perform accurate updates to player info, npc info and zones. + * @property addedWorldEntities the indices of all the world entities that were added within + * this cycle after it has been computed. The server can use this information to build the + * REBUILD_WORLDENTITY packet, which is used to actually render the instance itself. + * @property removedWorldEntities the indices of all the world entities that were removed + * within this cycle after it has been computed. These are only removed from the high resolution, + * and not necessarily the world itself. This allows the server to inform NPC and player info + * protocol to deallocate the instances. + * @property buffer the buffer for this world entity info packet. + * @property exception the exception that was caught during the computations + * of this world entity info, if any. This will be thrown as the [toPacketResult] + * function is called, allowing the server to handle it from the correct + * perspective of the caller, as the protocol itself is computed in for the + * entire server in one go. + */ +@Suppress("MemberVisibilityCanBePrivate", "DuplicatedCode") +public class WorldEntityInfo internal constructor( + internal var localIndex: Int, + internal val allocator: ByteBufAllocator, + private var oldSchoolClientType: OldSchoolClientType, + private val avatarRepository: WorldEntityAvatarRepository, + private val zoneIndexStorage: ZoneIndexStorage, + private val recycler: ByteBufRecycler = ByteBufRecycler(), +) : ReferencePooledObject { + private var renderDistance: Int = DEFAULT_RENDER_DISTANCE + private var zoneSeekRadius: Int = DEFAULT_ZONE_SEEK_RADIUS + private var coordInRootWorld: CoordGrid = CoordGrid.INVALID + private var rootBuildArea: BuildArea = BuildArea.INVALID + private var highResolutionIndicesCount: Int = 0 + private var highResolutionIndices: ShortArray = + ShortArray(WorldEntityProtocol.CAPACITY) { + INDEX_TERMINATOR + } + private var temporaryHighResolutionIndices: ShortArray = + ShortArray(WorldEntityProtocol.CAPACITY) { + INDEX_TERMINATOR + } + private val unsortedTopKArray: WorldEntityUnsortedTopKArray = WorldEntityUnsortedTopKArray(MAX_HIGH_RES_COUNT) + private val allWorldEntities = ArrayList() + private val allWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(allWorldEntities) + private val addedWorldEntities = ArrayList() + private val addedWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(addedWorldEntities) + private val removedWorldEntities = ArrayList() + private val removedWorldEntitiesUnmodifiable: List = Collections.unmodifiableList(removedWorldEntities) + private var buffer: ByteBuf? = null + + /** + * The previous world entity info packet that was created. + * We ensure that a server hasn't accidentally left a packet unwritten, which would + * de-synchronize the client and cause errors. + */ + internal var previousPacket: WorldEntityInfoV7Packet? = null + + @Volatile + internal var exception: Exception? = null + + override fun isDestroyed(): Boolean = this.exception != null + + /** + * Gets the world entity avatar with the provided [index]. + */ + internal fun getAvatar(index: Int): WorldEntityAvatar? { + return avatarRepository.getOrNull(index) + } + + /** + * Updates the render distance for this player, potentially allowing + * the world entities to be rendered from further away. All instances + * will however still be constrained to within the build area, in their + * entirety - if they cannot fulfill that constraint, they will not be + * put into high resolution view. + * @param distance the distance in tiles how far the world entities should + * be rendered from the player (or the camera's POV) + * @param zoneSeekRadius the radius in zones to search around the center point. + * By default, it will match old school which divides the distance by 8 and floors. + * This has a side effect of often searching a smaller distance than what it should. + * For example, a distance of 31 requires a zone seek radius of 4 to fully satisfy + * the distance, as the player can be in various positions within their current zone. + */ + @JvmOverloads + public fun updateRenderDistance( + distance: Int, + zoneSeekRadius: Int = distance ushr 3, + ) { + checkCommunicationThread() + if (isDestroyed()) return + this.renderDistance = distance + this.zoneSeekRadius = zoneSeekRadius + } + + /** + * Updates the build area for this player. This should always perfectly correspond to + * the actual build area that is sent via REBUILD_NORMAL or REBUILD_REGION packets. + * @param buildArea the build area in which everything is rendered. + */ + internal fun updateRootBuildArea(buildArea: BuildArea) { + checkCommunicationThread() + if (isDestroyed()) return + this.rootBuildArea = buildArea + } + + /** + * Gets a list of all the world entity indices that are currently in high resolution, + * allowing for correct functionality for player and npc infos, as well as zone updates. + * @return a list of indices of the world entities currently in high resolution. + */ + public fun getAllWorldEntityIndices(): List { + if (isDestroyed()) return emptyList() + return this.allWorldEntitiesUnmodifiable + } + + /** + * Gets the indices of all the world entities that were added to high resolution in this cycle, + * allowing the server to allocate new player and npc info instances, and sync the state of the + * zones in those world entities. + * @return a list of all the world entity indices added to the high resolution view in this + * cycle. + */ + public fun getAddedWorldEntityIndices(): List { + if (isDestroyed()) return emptyList() + return this.addedWorldEntitiesUnmodifiable + } + + /** + * Gets the indices of all the world entities that were removed from the high resolution in + * this cycle, allowing the server to destroy the player and npc info instances corresponding + * to them, and to clear the zones that were being tracked due to it. + * @return a list of all the indices of the world entities that were removed from the high + * resolution view this cycle. + */ + public fun getRemovedWorldEntityIndices(): List { + if (isDestroyed()) return emptyList() + return this.removedWorldEntitiesUnmodifiable + } + + /** + * Updates the current real absolute coordinate of the local player in the world. + * @param coordGrid the root coordgrid of the player. + */ + public fun updateRootCoord(coordGrid: CoordGrid) { + checkCommunicationThread() + if (isDestroyed()) return + this.coordInRootWorld = coordGrid + } + + /** + * Returns the backing byte buffer for this world entity info instance. + * @return the byte buffer instance into which all the world entity info + * is being written. + * @throws IllegalStateException if the buffer has not yet been allocated. + */ + @Throws(IllegalStateException::class) + public fun backingBuffer(): ByteBuf = checkNotNull(buffer) + + /** + * Turns the previously-computed world entity info into a packet instance + * which can be flushed to the client, or an exception if one was thrown while + * building the packet. + * @return the world entity packet instance in a [PacketResult]. + */ + internal fun toPacketResult(): PacketResult { + val exception = this.exception + if (exception != null) { + return PacketResult.failure( + InfoProcessException( + "Exception occurred during player info processing for index $localIndex", + exception, + ), + ) + } + val previousPacket = + previousPacket + ?: return PacketResult.failure( + IllegalStateException("Previous world entity info packet not calculated."), + ) + return PacketResult.success(previousPacket) + } + + /** + * Allocates a new buffer for the next world entity info packet. + * Furthermore, resets some temporary properties from the last cycle. + * @return the buffer into which everything is written about this packet. + */ + private fun allocBuffer(): ByteBuf { + // Acquire a new buffer with each cycle, in case the previous one isn't fully written out yet + val buffer = allocator.buffer(BUF_CAPACITY, BUF_CAPACITY) + this.buffer = buffer + recycler += buffer + this.addedWorldEntities.clear() + this.removedWorldEntities.clear() + return buffer + } + + /** + * Defragments the indices of the high resolution world entities. + * This is done only if world entities were removed in the middle of + * the array. + */ + private fun defragmentIndices() { + var count = 0 + for (i in highResolutionIndices.indices) { + if (count >= highResolutionIndicesCount) { + break + } + val index = highResolutionIndices[i] + if (index != INDEX_TERMINATOR) { + temporaryHighResolutionIndices[count++] = index + } + } + val uncompressed = this.highResolutionIndices + this.highResolutionIndices = this.temporaryHighResolutionIndices + this.temporaryHighResolutionIndices = uncompressed + } + + /** + * Performs the full world entity info update for the given player. + */ + internal fun updateWorldEntities() { + val buffer = allocBuffer().toJagByteBuf() + search() + val fragmented = processHighResolution(buffer) + if (fragmented) { + defragmentIndices() + } + processLowResolution(buffer) + } + + /** + * Sets up the packet to be consumed with the next call. + */ + internal fun postUpdate() { + if (this.previousPacket?.isConsumed() == false) { + logger.warn { + "Previous world entity info packet was calculated but " + + "not sent out to the client for player index $localIndex!" + } + } + val packet = WorldEntityInfoV7Packet(backingBuffer()) + this.previousPacket = packet + } + + /** + * Processes all the currently tracked high resolution world entities. + * @param buffer the buffer into which to write the high resolution updates. + * @return whether any world entities were removed from high resolution, meaning + * a defragmentation process is necessary. + */ + private fun processHighResolution(buffer: JagByteBuf): Boolean { + val count = this.highResolutionIndicesCount + + // Iterate worlds in a backwards order until the first world which should not be removed + // everyone else will be automatically dropped off by the client if the count + // transmitted is less than what the client currently knows about + for (i in count - 1 downTo 0) { + val index = this.highResolutionIndices[i].toInt() + val avatar = avatarRepository.getOrNull(index) + val needsRemoving = avatar == null || !this.unsortedTopKArray.contains(index) || isReallocated(avatar) + if (!needsRemoving) { + break + } + + highResolutionIndices[i] = INDEX_TERMINATOR + this.highResolutionIndicesCount-- + this.removedWorldEntities += index + allWorldEntities -= index + } + + buffer.p1(this.highResolutionIndicesCount) + for (i in 0..= MAX_HIGH_RES_COUNT) { + return + } + val topKArray = this.unsortedTopKArray + val indices = topKArray.indices + val length = topKArray.size + for (k in 0..= MAX_HIGH_RES_COUNT) { + break + } + val avatar = avatarRepository.getOrNull(index) ?: continue + addedWorldEntities += index + allWorldEntities += index + val i = highResolutionIndicesCount++ + highResolutionIndices[i] = index.toShort() + val priority = avatar.priorityTowards(localIndex) + val fineXOffset = rootBuildArea.zoneX shl 10 + val fineZOffset = rootBuildArea.zoneZ shl 10 + + buffer.p2(avatar.index) + buffer.p1((avatar.sizeX shl 4) or avatar.sizeZ) + val placeholderFlag = pPlaceholderExtendedInfoFlag(buffer) + buffer.p1Alt2(priority.id) + buffer.p2Alt2(avatar.id) + buffer.encodeAngledCoordFine( + avatar.currentCoordFine.x - fineXOffset, + avatar.currentCoordFine.y, + avatar.currentCoordFine.z - fineZOffset, + avatar.angle, + ) + putWorldEntityExtendedInfo(avatar, buffer, placeholderFlag) + } + } + + private fun pPlaceholderExtendedInfoFlag(buffer: JagByteBuf): Int { + val index = buffer.writerIndex() + buffer.p1(0) + return index + } + + private fun putWorldEntityExtendedInfo( + avatar: WorldEntityAvatar, + buffer: JagByteBuf, + flagWriteIndex: Int, + ) { + // No extra flags right now as the extended info system is still primitive + avatar.extendedInfo.pExtendedInfo( + oldSchoolClientType, + buffer, + localIndex, + 0, + flagWriteIndex, + ) + } + + /** + * Checks if the world entity at [index] is in high resolution indices. + * @return whether the world entity is within high resolution. + */ + internal fun isHighResolution(index: Int): Boolean { + for (i in 0.. maxBuildAreaZoneX || maxAvatarZoneZ > maxBuildAreaZoneZ) +} + +internal fun JagByteBuf.encodeAngledCoordFine( + x: Int, + y: Int, + z: Int, + angle: Int, +) { + val marker = writerIndex() + p1(0) + var opcode = 0 + if (x != 0) { + opcode = opcode or runLengthEncode(x) + } + if (y != 0) { + opcode = opcode or (runLengthEncode(y) shl 2) + } + if (z != 0) { + opcode = opcode or (runLengthEncode(z) shl 4) + } + if (angle != 0) { + opcode = opcode or (runLengthEncode(angle) shl 6) + } + val index = writerIndex() + writerIndex(marker) + p1(opcode) + writerIndex(index) +} + +private fun JagByteBuf.runLengthEncode(value: Int): Int { + when (value) { + 0 -> { + return 0 + } + in Byte.MIN_VALUE..Byte.MAX_VALUE -> { + p1(value) + return 1 + } + in Short.MIN_VALUE..Short.MAX_VALUE -> { + p2(value) + return 2 + } + else -> { + p4(value) + return 3 + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoRepository.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoRepository.kt new file mode 100644 index 000000000..613d58709 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoRepository.kt @@ -0,0 +1,37 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.game.outgoing.info.InfoRepository + +/** + * A repository for world entity info instances. + * @param allocator the allocator used to return a new or re-used world entity info + * instance, based on the provided player's index and client type. + * @property elements the array of currently in used world entity info objects. + */ +internal class WorldEntityInfoRepository( + allocator: ( + index: Int, + oldSchoolClientType: OldSchoolClientType, + unit: Unit, + ) -> WorldEntityInfo, +) : InfoRepository(allocator) { + override val elements: Array = arrayOfNulls(WorldEntityProtocol.CAPACITY) + + override fun informDeallocation(idx: Int) { + // No-op + } + + override fun onDealloc(element: WorldEntityInfo) { + element.onDealloc() + } + + override fun onAlloc( + element: WorldEntityInfo, + idx: Int, + oldSchoolClientType: OldSchoolClientType, + newInstance: Boolean, + ) { + element.onAlloc(idx, oldSchoolClientType, newInstance) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoV7Packet.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoV7Packet.kt new file mode 100644 index 000000000..56d209d40 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfoV7Packet.kt @@ -0,0 +1,31 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.ConsumableMessage +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * World entity info packet is used to update the coordinate, angle and move speed of all + * the world entities near a player. + */ +public class WorldEntityInfoV7Packet( + buffer: ByteBuf, +) : DefaultByteBufHolder(buffer), + OutgoingGameMessage, + ConsumableMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + private var consumed: Boolean = false + + override fun consume() { + this.consumed = true + } + + override fun isConsumed(): Boolean = this.consumed + + override fun toString(): String = "WorldEntityInfoV7Packet()" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityMap.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityMap.kt new file mode 100644 index 000000000..ef858bfb3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityMap.kt @@ -0,0 +1,252 @@ +package net.rsprot.protocol.game.outgoing.info.worldentityinfo + +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid + +/** + * A primitive hash map implementation to store which world entity owns a specific zones. + * This map will track every zone used up by the instance, so we can do simple [CoordGrid] -> [ZoneCoord] + * conversions and look up the respective world entity index that owns it, if any. + * @property map the primitive hashmap keeping track of . + */ +internal class WorldEntityMap( + initialCapacity: Int = 10_000, +) { + private val map: Int2IntMap = + Int2IntOpenHashMap(initialCapacity).apply { + defaultReturnValue(-1) + } + + /** + * Tries to store the world entity with [index] in the provided rectangle of zones. + * If an exception is thrown while marking the rectangle, anything that was marked will be unmarked + * before the exception is re-thrown. + * @param southWestZoneX the zone x coordinate of the south-westernmost zone in the instance. + * @param southWestZoneZ the zone z coordinate of the south-westernmost zone in the instance. + * @param widthInZones the width of the instance in zones. + * A value of 1 means the instance has a width of 8 tiles. + * @param lengthInZones the length of the instance in zones. + * A value of 1 means the instance has a length of 8 tiles. + * @param minLevel the minimum level occupied by the instance. + * @param maxLevel the maximum level occupied by the instance. This is inclusive. + * Note that it is not possible to make an instance which skips a level, as any such instance is illogical. + * @param index the index of the world entity that owns this slab of land. + */ + fun put( + southWestZoneX: Int, + southWestZoneZ: Int, + widthInZones: Int, + lengthInZones: Int, + minLevel: Int, + maxLevel: Int, + index: Int, + ) { + try { + fillRectangle( + southWestZoneX, + southWestZoneZ, + widthInZones, + lengthInZones, + minLevel, + maxLevel, + index, + ) + } catch (e: Throwable) { + // Clean up any that were partially filled with the provided index + clearRectangle( + southWestZoneX, + southWestZoneZ, + widthInZones, + lengthInZones, + minLevel, + maxLevel, + index, + ) + + // Re-throw the old exception to let the caller handle it + throw e + } + } + + /** + * Removes the world entity with [expectedIndex] in the provided rectangle of zones. + * @param southWestZoneX the zone x coordinate of the south-westernmost zone in the instance. + * @param southWestZoneZ the zone z coordinate of the south-westernmost zone in the instance. + * @param widthInZones the width of the instance in zones. + * A value of 1 means the instance has a width of 8 tiles. + * @param lengthInZones the length of the instance in zones. + * A value of 1 means the instance has a length of 8 tiles. + * @param minLevel the minimum level occupied by the instance. + * @param maxLevel the maximum level occupied by the instance. This is inclusive. + * Note that it is not possible to make an instance which skips a level, as any such instance is illogical. + */ + fun remove( + southWestZoneX: Int, + southWestZoneZ: Int, + widthInZones: Int, + lengthInZones: Int, + minLevel: Int, + maxLevel: Int, + expectedIndex: Int, + ) { + clearRectangle( + southWestZoneX, + southWestZoneZ, + widthInZones, + lengthInZones, + minLevel, + maxLevel, + expectedIndex, + ) + } + + /** + * Gets the world entity index that owns the zone that the [coordGrid] belongs in, or -1 if none is present. + * @return world entity id that owns the zone, or -1. + */ + fun get(coordGrid: CoordGrid): Int { + return map.get(coordGrid.toZoneCoord().packed) + } + + /** + * Fills the land with [index]. + * @param southWestZoneX the zone x coordinate of the south-westernmost zone in the instance. + * @param southWestZoneZ the zone z coordinate of the south-westernmost zone in the instance. + * @param widthInZones the width of the instance in zones. + * A value of 1 means the instance has a width of 8 tiles. + * @param lengthInZones the length of the instance in zones. + * A value of 1 means the instance has a length of 8 tiles. + * @param minLevel the minimum level occupied by the instance. + * @param maxLevel the maximum level occupied by the instance. This is inclusive. + * Note that it is not possible to make an instance which skips a level, as any such instance is illogical. + * @param index the index of the world entity that owns this slab of land. + */ + private fun fillRectangle( + southWestZoneX: Int, + southWestZoneZ: Int, + widthInZones: Int, + lengthInZones: Int, + minLevel: Int, + maxLevel: Int, + index: Int, + ) { + for (level in minLevel..maxLevel) { + for (offsetXInZones in 0.. + WorldEntityInfo( + localIndex, + allocator, + clientType, + factory.avatarRepository, + zoneIndexStorage, + recycler, + ) + } + + /** + * The list of [Callable] instances which perform the jobs for player info. + * This list itself is re-used throughout the lifespan of the application, + * but the [Callable] instances themselves are generated for every job. + */ + private val callables: MutableList> = ArrayList(CAPACITY) + + /** + * Allocates a new instance of world entity info. + * @param idx the index of the player who is requesting a world entity info. + * @param oldSchoolClientType the client type on which the player has logged in. + * @return an instance of the world entity info. + */ + internal fun alloc( + idx: Int, + oldSchoolClientType: OldSchoolClientType, + ): WorldEntityInfo { + checkCommunicationThread() + return worldEntityInfoRepository.alloc(idx, oldSchoolClientType, Unit) + } + + /** + * Deallocates the world entity info, allowing for it to be re-used in the future. + * @param info the world entity info to be deallocated. + */ + internal fun dealloc(info: WorldEntityInfo) { + checkCommunicationThread() + // Prevent returning a destroyed worldentity info object back into the pool + if (info.isDestroyed()) { + return + } + worldEntityInfoRepository.dealloc(info.localIndex) + } + + /** + * Updates all the world entities that exist in one go. + */ + public fun update() { + checkCommunicationThread() + prepareHighResolutionBuffers() + updateInfos() + postUpdate() + recycler.cycle() + cycleCount++ + } + + /** + * Pre-computes the high resolution block of world entities that exist. + */ + private fun prepareHighResolutionBuffers() { + for (i in 0.. Unit) { + for (i in 1.., + public val events: List, +) : OutgoingGameMessage { + public constructor( + topLevelInterface: Int, + subInterfaces: List, + events: List, + ) : this( + topLevelInterface.toUShort(), + subInterfaces, + events, + ) + + public val topLevelInterface: Int + get() = _topLevelInterface.toIntOrMinusOne() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int = + Short.SIZE_BYTES + + Short.SIZE_BYTES + + (subInterfaces.size * (Int.SIZE_BYTES + Short.SIZE_BYTES + Byte.SIZE_BYTES)) + + (events.size * (Int.SIZE_BYTES + Short.SIZE_BYTES + Short.SIZE_BYTES + Int.SIZE_BYTES + Int.SIZE_BYTES)) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfResyncV2 + + if (_topLevelInterface != other._topLevelInterface) return false + if (subInterfaces != other.subInterfaces) return false + if (events != other.events) return false + + return true + } + + override fun hashCode(): Int { + var result = _topLevelInterface.hashCode() + result = 31 * result + subInterfaces.hashCode() + result = 31 * result + events.hashCode() + return result + } + + override fun toString(): String = + "IfResyncV2(" + + "topLevelInterface=$topLevelInterface, " + + "subInterfaces=$subInterfaces, " + + "events=$events" + + ")" + + /** + * Sub interface holds state about a sub interface to be opened. + * @property destinationCombinedId the bitpacked combination of [destinationInterfaceId] and [destinationComponentId]. + * @property destinationInterfaceId the destination interface on which the sub + * interface is being opened + * @property destinationComponentId the component on the destination interface + * on which the sub interface is being opened + * @property interfaceId the sub interface id + * @property type the type of the interface to be opened as (modal, overlay, client) + */ + @Suppress("MemberVisibilityCanBePrivate") + public class SubInterfaceMessage private constructor( + public val destinationCombinedId: Int, + private val _interfaceId: UShort, + private val _type: UByte, + ) { + public constructor( + destinationInterfaceId: Int, + destinationComponentId: Int, + interfaceId: Int, + type: Int, + ) : this( + CombinedId(destinationInterfaceId, destinationComponentId).combinedId, + interfaceId.toUShort(), + type.toUByte(), + ) + + public constructor( + destinationCombinedId: Int, + interfaceId: Int, + type: Int, + ) : this( + destinationCombinedId, + interfaceId.toUShort(), + type.toUByte(), + ) + + private val _destinationCombinedId: CombinedId + get() = CombinedId(destinationCombinedId) + public val destinationInterfaceId: Int + get() = _destinationCombinedId.interfaceId + public val destinationComponentId: Int + get() = _destinationCombinedId.componentId + public val interfaceId: Int + get() = _interfaceId.toIntOrMinusOne() + public val type: Int + get() = _type.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SubInterfaceMessage + + if (destinationCombinedId != other.destinationCombinedId) return false + if (_interfaceId != other._interfaceId) return false + if (_type != other._type) return false + + return true + } + + override fun hashCode(): Int { + var result = destinationCombinedId.hashCode() + result = 31 * result + _interfaceId.hashCode() + result = 31 * result + _type.hashCode() + return result + } + + override fun toString(): String = + "SubInterfaceMessage(" + + "destinationInterfaceId=$destinationInterfaceId, " + + "destinationComponentId=$destinationComponentId, " + + "interfaceId=$interfaceId, " + + "type=$type" + + ")" + } + + /** + * Interface events compress the IF_SETEVENTS packet's payload + * into a helper class. + * @property interfaceId the interface id on which to set the events + * @property componentId the component on that interface to set the events on + * @property start the start subcomponent id + * @property end the end subcomponent id (inclusive) + * @property events1 the bitpacked events. Note that ifbutton is no longer included in this, + * so bits 1..10 are ignored. + * @property events2 the bitpacked ifbutton events. Each bit corresponds to the respective + * button, including the sign bit. + */ + public class InterfaceEventsMessage private constructor( + public val combinedId: Int, + private val _start: UShort, + private val _end: UShort, + public val events1: Int, + public val events2: Int, + ) { + public constructor( + interfaceId: Int, + componentId: Int, + start: Int, + end: Int, + events1: Int, + events2: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + start.toUShort(), + end.toUShort(), + events1, + events2, + ) + + public constructor( + combinedId: Int, + start: Int, + end: Int, + events1: Int, + events2: Int, + ) : this( + combinedId, + start.toUShort(), + end.toUShort(), + events1, + events2, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val start: Int + get() = _start.toInt() + public val end: Int + get() = _end.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as InterfaceEventsMessage + + if (combinedId != other.combinedId) return false + if (_start != other._start) return false + if (_end != other._end) return false + if (events1 != other.events1) return false + if (events2 != other.events2) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _start.hashCode() + result = 31 * result + _end.hashCode() + result = 31 * result + events1 + result = 31 * result + events2 + return result + } + + override fun toString(): String = + "InterfaceEvents(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "start=$start, " + + "end=$end, " + + "events1=$events1, " + + "events2=$events2" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAngle.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAngle.kt new file mode 100644 index 000000000..7b083d9c8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAngle.kt @@ -0,0 +1,94 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-angle is used to change the angle of a model on an interface component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which the component resides + * @property componentId the component id on which the model resides + * @property angleX the new x model angle to set to, a value from 0 to 2047 (inclusive) + * @property angleY the new y model angle to set to, a value from 0 to 2047 (inclusive) + * @property zoom the zoom of the model, defaults to a value of 100 in the client. + * The greater the [zoom] value, the smaller the model will appear - it is inverted. + */ +public class IfSetAngle private constructor( + public val combinedId: Int, + private val _angleX: UShort, + private val _angleY: UShort, + private val _zoom: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + angleX: Int, + angleY: Int, + zoom: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + angleX.toUShort(), + angleY.toUShort(), + zoom.toUShort(), + ) + + public constructor( + combinedId: Int, + angleX: Int, + angleY: Int, + zoom: Int, + ) : this( + combinedId, + angleX.toUShort(), + angleY.toUShort(), + zoom.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val angleX: Int + get() = _angleX.toInt() + public val angleY: Int + get() = _angleY.toInt() + public val zoom: Int + get() = _zoom.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetAngle + + if (combinedId != other.combinedId) return false + if (_angleX != other._angleX) return false + if (_angleY != other._angleY) return false + if (_zoom != other._zoom) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _angleX.hashCode() + result = 31 * result + _angleY.hashCode() + result = 31 * result + _zoom.hashCode() + return result + } + + override fun toString(): String = + "IfSetAngle(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "angleX=$angleX, " + + "angleY=$angleY, " + + "zoom=$zoom" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAnim.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAnim.kt new file mode 100644 index 000000000..367a9bd8d --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetAnim.kt @@ -0,0 +1,72 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * If set-anim is used to make a model animate on a component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the model resides + * @property componentId the id of the component on which the model resides + * @property anim the id of the animation to play, or -1 to reset the animation + */ +public class IfSetAnim private constructor( + public val combinedId: Int, + private val _anim: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + anim: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + anim.toUShort(), + ) + + public constructor( + combinedId: Int, + anim: Int, + ) : this( + combinedId, + anim.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val anim: Int + get() = _anim.toIntOrMinusOne() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetAnim + + if (combinedId != other.combinedId) return false + if (_anim != other._anim) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _anim.hashCode() + return result + } + + override fun toString(): String = + "IfSetAnim(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "anim=$anim" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetColour.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetColour.kt new file mode 100644 index 000000000..495abd045 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetColour.kt @@ -0,0 +1,191 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId +import java.awt.Color + +/** + * If set-colour is used to set the colour of a text component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the text resides + * @property componentId the id of the component on which the text resides + * @property red the value of the red colour, ranging from 0 to 31 (inclusive) + * @property green the value of the green colour, ranging from 0 to 31 (inclusive) + * @property blue the value of the blue colour, ranging from 0 to 31 (inclusive) + */ +@Suppress("MemberVisibilityCanBePrivate") +public class IfSetColour private constructor( + public val combinedId: Int, + private val colour: Rs15BitColour, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + red: Int, + green: Int, + blue: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + Rs15BitColour( + red, + green, + blue, + ), + ) + + public constructor( + combinedId: Int, + red: Int, + green: Int, + blue: Int, + ) : this( + combinedId, + Rs15BitColour( + red, + green, + blue, + ), + ) + + public constructor( + combinedId: Int, + colour15BitPacked: Int, + ) : this( + combinedId, + Rs15BitColour(colour15BitPacked.toUShort()), + ) + + /** + * A secondary constructor to build a colour from [Color]. + * This can be useful to avoid manual colour conversions, + * as 8-bit colours are typically used. + * This function will strip away the 3 least significant + * bits from the colours, as Jagex's colour format only expects + * 5 bits per colour, so small changes in tone may occur. + */ + public constructor( + interfaceId: Int, + componentId: Int, + color: Color, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + Rs15BitColour( + color.red ushr 3, + color.green ushr 3, + color.blue ushr 3, + ), + ) + + /** + * A secondary constructor to build a colour from [Color]. + * This can be useful to avoid manual colour conversions, + * as 8-bit colours are typically used. + * This function will strip away the 3 least significant + * bits from the colours, as Jagex's colour format only expects + * 5 bits per colour, so small changes in tone may occur. + */ + public constructor( + combinedId: Int, + color: Color, + ) : this( + combinedId, + Rs15BitColour( + color.red ushr 3, + color.green ushr 3, + color.blue ushr 3, + ), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val red: Int + get() = colour.red + public val green: Int + get() = colour.green + public val blue: Int + get() = colour.blue + public val colour15BitPacked: Int + get() = colour.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + /** + * Turns the 15-bit RS RGB colour into a 24-bit normalized RGB colour. + */ + public fun toAwtColor(): Color = + Color( + (red shl 19) + .or(green shl 11) + .or(blue shl 3), + ) + + @JvmInline + private value class Rs15BitColour( + val packed: UShort, + ) { + constructor( + red: Int, + green: Int, + blue: Int, + ) : this( + (red and 0x1F shl 10) + .or(green and 0x1F shl 5) + .or(blue and 0x1F) + .toUShort(), + ) + + val red: Int + get() = packed.toInt() ushr 10 and 0x1F + val green: Int + get() = packed.toInt() ushr 5 and 0x1F + val blue: Int + get() = packed.toInt() and 0x1F + + override fun toString(): String = + "Rs15BitColour(" + + "red=$red, " + + "green=$green, " + + "blue=$blue" + + ")" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetColour + + if (combinedId != other.combinedId) return false + if (colour != other.colour) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + colour.hashCode() + return result + } + + @OptIn(ExperimentalStdlibApi::class) + override fun toString(): String { + val packed = + (red shl 19) + .or(green shl 11) + .or(blue shl 3) + return "IfSetColour(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "red=$red/31, " + + "green=$green/31, " + + "blue=$blue/31, " + + "24-bit RGB colour=${packed.toHexString(HexFormat.UpperCase)}" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetEventsV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetEventsV2.kt new file mode 100644 index 000000000..169517eb7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetEventsV2.kt @@ -0,0 +1,103 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * Interface events v2 are sent to set/unlock various options on a component, + * such as button clicks and dragging. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which to set the events + * @property componentId the component on that interface to set the events on + * @property start the start subcomponent id + * @property end the end subcomponent id (inclusive) + * @property events1 the bitpacked events. Note that ifbutton is no longer included in this, + * so bits 1..10 are ignored. + * @property events2 the bitpacked ifbutton events. Each bit corresponds to the respective + * button, including the sign bit. + */ +public class IfSetEventsV2 private constructor( + public val combinedId: Int, + private val _start: UShort, + private val _end: UShort, + public val events1: Int, + public val events2: Int, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + start: Int, + end: Int, + events1: Int, + events2: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + start.toUShort(), + end.toUShort(), + events1, + events2, + ) + + public constructor( + combinedId: Int, + start: Int, + end: Int, + events1: Int, + events2: Int, + ) : this( + combinedId, + start.toUShort(), + end.toUShort(), + events1, + events2, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val start: Int + get() = _start.toInt() + public val end: Int + get() = _end.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetEventsV2 + + if (combinedId != other.combinedId) return false + if (_start != other._start) return false + if (_end != other._end) return false + if (events1 != other.events1) return false + if (events2 != other.events2) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _start.hashCode() + result = 31 * result + _end.hashCode() + result = 31 * result + events1 + result = 31 * result + events2 + return result + } + + override fun toString(): String = + "IfSetEventsV2(" + + "events1=$events1, " + + "events2=$events2, " + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "start=$start, " + + "end=$end" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetHide.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetHide.kt new file mode 100644 index 000000000..547f5a105 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetHide.kt @@ -0,0 +1,61 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-hide is used to hide or unhide a component and its children on an interface. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which the component to hide or unhide resides on + * @property componentId the component on the [interfaceId] to hide or unhide + * @property hidden whether to hide or unhide the component + */ +public class IfSetHide( + public val combinedId: Int, + public val hidden: Boolean, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + hidden: Boolean, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + hidden, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetHide + + if (combinedId != other.combinedId) return false + if (hidden != other.hidden) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + hidden.hashCode() + return result + } + + override fun toString(): String = + "IfSetHide(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "hidden=$hidden" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetModel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetModel.kt new file mode 100644 index 000000000..1d5456c05 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetModel.kt @@ -0,0 +1,72 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set model packet is used to set a model to render on an interface. + * The component must be of model type for this to succeed. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which to set the events + * @property componentId the component on that interface to set the events on + * @property model the id of the model to render. + */ +public class IfSetModel private constructor( + public val combinedId: Int, + private val _model: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + model: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + model.toUShort(), + ) + + public constructor( + combinedId: Int, + model: Int, + ) : this( + combinedId, + model.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val model: Int + get() = _model.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetModel + + if (combinedId != other.combinedId) return false + if (_model != other._model) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _model.hashCode() + return result + } + + override fun toString(): String = + "IfSetModel(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "model=$model" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHead.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHead.kt new file mode 100644 index 000000000..11b057669 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHead.kt @@ -0,0 +1,73 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne +import net.rsprot.protocol.util.CombinedId + +/** + * If set-npc-head is used to set a npc's chathead on an interface, commonly + * in dialogues. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which the model resides + * @property componentId the component id on which the model resides + * @property npc the id of the npc config whose head to set as the model + */ +public class IfSetNpcHead private constructor( + public val combinedId: Int, + private val _npc: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + npc: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + npc.toUShort(), + ) + + public constructor( + combinedId: Int, + npc: Int, + ) : this( + combinedId, + npc.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val npc: Int + get() = _npc.toIntOrMinusOne() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetNpcHead + + if (combinedId != other.combinedId) return false + if (_npc != other._npc) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _npc.hashCode() + return result + } + + override fun toString(): String = + "IfSetNpcHead(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "npc=$npc" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHeadActive.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHeadActive.kt new file mode 100644 index 000000000..107ad455a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetNpcHeadActive.kt @@ -0,0 +1,75 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-npc-head-active is used to set a npc's chathead on an interface, commonly + * in dialogues. Rather than taking the id of the npc config, this function + * takes the index of the npc in the world. Npc's model is looked up from the + * client through npc info, allowing for the chatbox to render a custom-built + * npc with completely dynamic models, rather than the pre-defined configs. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which the model resides + * @property componentId the component id on which the model resides + * @property index the index of the npc in the world + */ +public class IfSetNpcHeadActive private constructor( + public val combinedId: Int, + private val _index: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + index: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + index.toUShort(), + ) + + public constructor( + combinedId: Int, + index: Int, + ) : this( + combinedId, + index.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val index: Int + get() = _index.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetNpcHeadActive + + if (combinedId != other.combinedId) return false + if (_index != other._index) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _index.hashCode() + return result + } + + override fun toString(): String = + "IfSetNpcHead(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "index=$index" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetObject.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetObject.kt new file mode 100644 index 000000000..d19b61385 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetObject.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * Sets an object on an interface component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface on which the component resides + * @property componentId the component on which the obj resides + * @property obj the id of the obj to set on the component + * @property count the count of the obj, used to obtain a different variant + * of the model of the obj + */ +public class IfSetObject private constructor( + public val combinedId: Int, + private val _obj: UShort, + public val count: Int, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + obj: Int, + count: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + obj.toUShort(), + count, + ) + + public constructor( + combinedId: Int, + obj: Int, + count: Int, + ) : this( + combinedId, + obj.toUShort(), + count, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val obj: Int + get() = _obj.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetObject + + if (combinedId != other.combinedId) return false + if (_obj != other._obj) return false + if (count != other.count) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _obj.hashCode() + result = 31 * result + count.hashCode() + return result + } + + override fun toString(): String = + "IfSetObject(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "obj=$obj, " + + "count=$count" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerHead.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerHead.kt new file mode 100644 index 000000000..51ec9c71a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerHead.kt @@ -0,0 +1,50 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-player-head is used to set the local player's chathead on an interface, + * commonly used for dialogues. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the chathead model resides + * @property componentId the id of the component on which the chathead model resides + */ +public class IfSetPlayerHead( + public val combinedId: Int, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPlayerHead + + return combinedId == other.combinedId + } + + override fun hashCode(): Int = combinedId.hashCode() + + override fun toString(): String = + "IfSetPlayerHead(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBaseColour.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBaseColour.kt new file mode 100644 index 000000000..ad43ac9c0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBaseColour.kt @@ -0,0 +1,85 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-player-model basecolour packet is used to set the ident kit colour + * of a customized player model on an interface. This allows one to build + * a completely unique player model up without using anyone as reference. + * The colouring logic is identical to that found within Appearance for players. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the model resides + * @property componentId the id of the component on which the model resides + * @property index the index of the colour, ranging from 0 to 4 (inclusive) + * @property colour the value of the colour, ranging from 0 to 255 (inclusive) + */ +public class IfSetPlayerModelBaseColour private constructor( + public val combinedId: Int, + private val _index: UByte, + private val _colour: UByte, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + index: Int, + colour: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + index.toUByte(), + colour.toUByte(), + ) + + public constructor( + combinedId: Int, + index: Int, + colour: Int, + ) : this( + combinedId, + index.toUByte(), + colour.toUByte(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val index: Int + get() = _index.toInt() + public val colour: Int + get() = _colour.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPlayerModelBaseColour + + if (combinedId != other.combinedId) return false + if (_index != other._index) return false + if (_colour != other._colour) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _index.hashCode() + result = 31 * result + _colour.hashCode() + return result + } + + override fun toString(): String = + "IfSetPlayerModelBaseColour(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "index=$index, " + + "colour=$colour" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBodyType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBodyType.kt new file mode 100644 index 000000000..abaf672d7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelBodyType.kt @@ -0,0 +1,73 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If setplayermodel bodytype is used to change the current body-type of + * a player model on an interface, making the client prefer swap out + * the models for the respective type. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the model resides + * @property componentId the id of the component on which the model resides + * @property bodyType the new body-type to set to the player model + */ +public class IfSetPlayerModelBodyType private constructor( + public val combinedId: Int, + private val _bodyType: UByte, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + bodyType: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + bodyType.toUByte(), + ) + + public constructor( + combinedId: Int, + bodyType: Int, + ) : this( + combinedId, + bodyType.toUByte(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val bodyType: Int + get() = _bodyType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPlayerModelBodyType + + if (combinedId != other.combinedId) return false + if (_bodyType != other._bodyType) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _bodyType.hashCode() + return result + } + + override fun toString(): String = + "IfSetPlayerModelBodyType(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "bodyType=$bodyType" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelObj.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelObj.kt new file mode 100644 index 000000000..082bcf339 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelObj.kt @@ -0,0 +1,63 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If setplayermodel obj is used to set a worn obj on a player model. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the model resides + * @property componentId the id of the component on which the model resides + * @property obj the id of the obj. Interestingly, the client reads a 32-bit int + * for the obj, even though configs having a strict 32767/65535 limitation elsewhere + * in the client. + */ +public class IfSetPlayerModelObj( + public val combinedId: Int, + public val obj: Int, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + obj: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + obj, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPlayerModelObj + + if (combinedId != other.combinedId) return false + if (obj != other.obj) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + obj + return result + } + + override fun toString(): String = + "IfSetPlayerModelObj(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "obj=$obj" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelSelf.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelSelf.kt new file mode 100644 index 000000000..a21cfba40 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPlayerModelSelf.kt @@ -0,0 +1,62 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If setplayermodel self is used to set the player model on an interface + * to that of the local player. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the model resides + * @property componentId the id of the component on which the model resides + * @property copyObjs whether to copy all the worn objs over as well + */ +public class IfSetPlayerModelSelf( + public val combinedId: Int, + public val copyObjs: Boolean, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + copyObjs: Boolean, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + copyObjs, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPlayerModelSelf + + if (combinedId != other.combinedId) return false + if (copyObjs != other.copyObjs) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + copyObjs.hashCode() + return result + } + + override fun toString(): String = + "IfSetPlayerModelSelf(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "copyObjs=$copyObjs" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPosition.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPosition.kt new file mode 100644 index 000000000..ac241c47c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetPosition.kt @@ -0,0 +1,82 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-position events are used to move a component on an interface. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface on which the component to move exists + * @property componentId the component id to move + * @property x the x coordinate to move to + * @property y the y coordinate to move to + */ +public class IfSetPosition private constructor( + public val combinedId: Int, + private val _x: UShort, + private val _y: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + x: Int, + y: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + x.toUShort(), + y.toUShort(), + ) + + public constructor( + combinedId: Int, + x: Int, + y: Int, + ) : this( + combinedId, + x.toUShort(), + y.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val x: Int + get() = _x.toInt() + public val y: Int + get() = _y.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetPosition + + if (combinedId != other.combinedId) return false + if (_x != other._x) return false + if (_y != other._y) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _x.hashCode() + result = 31 * result + _y.hashCode() + return result + } + + override fun toString(): String = + "IfSetPosition(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "x=$x, " + + "y=$y" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetRotateSpeed.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetRotateSpeed.kt new file mode 100644 index 000000000..b431c7503 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetRotateSpeed.kt @@ -0,0 +1,89 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set-rotate-speed packet is used to make a model rotate + * according to the client's update counter. This only has an effect + * on model-type components. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the id of the interface on which the component to rotate + * lives. + * @property componentId the component on which the model to rotate lives + * @property xSpeed the speed of the x angle of the model to rotate by + * each client cycle (20ms/cc), with a value of 1 being equal to 1/2048th of a + * full circle + * @property ySpeed the speed of the y angle of the model to rotate by + * each client cycle (20ms/cc), with a value of 1 being equal to 1/2048th of a + * full circle + */ +public class IfSetRotateSpeed private constructor( + public val combinedId: Int, + private val _xSpeed: UShort, + private val _ySpeed: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + xSpeed: Int, + ySpeed: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + xSpeed.toUShort(), + ySpeed.toUShort(), + ) + + public constructor( + combinedId: Int, + xSpeed: Int, + ySpeed: Int, + ) : this( + combinedId, + xSpeed.toUShort(), + ySpeed.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val xSpeed: Int + get() = _xSpeed.toInt() + public val ySpeed: Int + get() = _ySpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetRotateSpeed + + if (combinedId != other.combinedId) return false + if (_xSpeed != other._xSpeed) return false + if (_ySpeed != other._ySpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _xSpeed.hashCode() + result = 31 * result + _ySpeed.hashCode() + return result + } + + override fun toString(): String = + "IfSetRotateSpeed(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "xSpeed=$xSpeed, " + + "ySpeed=$ySpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetScrollPos.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetScrollPos.kt new file mode 100644 index 000000000..5d8885d84 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetScrollPos.kt @@ -0,0 +1,72 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * If set scroll pos messages are used to force the scroll position + * of a layer component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface on which the scroll layer exists + * @property componentId the component id of the scroll layer + * @property scrollPos the scroll position to set to + */ +public class IfSetScrollPos private constructor( + public val combinedId: Int, + private val _scrollPos: UShort, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + scrollPos: Int, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + scrollPos.toUShort(), + ) + + public constructor( + combinedId: Int, + scrollPos: Int, + ) : this( + combinedId, + scrollPos.toUShort(), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val scrollPos: Int + get() = _scrollPos.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetScrollPos + + if (combinedId != other.combinedId) return false + if (_scrollPos != other._scrollPos) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _scrollPos.hashCode() + return result + } + + override fun toString(): String = + "IfSetScrollPos(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "scrollPos=$scrollPos" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetText.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetText.kt new file mode 100644 index 000000000..eb19427f8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/interfaces/IfSetText.kt @@ -0,0 +1,65 @@ +package net.rsprot.protocol.game.outgoing.interfaces + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize +import net.rsprot.protocol.util.CombinedId + +/** + * If set-text packet is used to set the text on a text component. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the interface id on which the text resides + * @property componentId the component id on the interface on which the text + * resides + * @property text the text to assign + */ +public class IfSetText( + public val combinedId: Int, + public val text: String, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + text: String, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + text, + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int = Int.SIZE_BYTES + estimateTextSize(text) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IfSetText + + if (combinedId != other.combinedId) return false + if (text != other.text) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + text.hashCode() + return result + } + + override fun toString(): String = + "IfSetText(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "text='$text'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvFull.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvFull.kt new file mode 100644 index 000000000..8bc8e02da --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvFull.kt @@ -0,0 +1,191 @@ +package net.rsprot.protocol.game.outgoing.inv + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.common.game.outgoing.inv.InventoryObject +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.game.outgoing.inv.internal.Inventory +import net.rsprot.protocol.internal.game.outgoing.inv.internal.InventoryPool +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * Update inv full is used to perform a full synchronization of an inventory's + * contents to the client. + * The client will wipe any existing cache of this inventory prior to performing + * an update. + * While not very well known, it is possible to send less objs than the inventory's + * respective capacity in the cache. As an example, if the inventory's capacity + * in the cache is 500, but the inv only has a single object at the first slot, + * a simple compression method is to send the capacity as 1 to the client, + * and only inform of the single object that does exist - all others would be + * presumed non-existent. There is no need to transmit all 500 slots when + * the remaining 499 are not filled, saving considerable amount of space in the + * process. + * + * @property combinedId the combined id of the interface and the component id. + * For IF3-type interfaces, only negative values are allowed. + * If one wishes to make the inventory a "mirror", e.g. for trading, + * how both the player's own and the partner's inventory share the id, + * a value of < -70000 is expected, this tells the client that the respective + * inventory is a "mirrored" one. + * For normal IF3 interfaces, a value of -1 is perfectly acceptable. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the IF1 interface on which the inventory lies. + * For IF3 interfaces, no [interfaceId] should be provided. + * @property componentId the component on which the inventory lies + * @property inventoryId the id of the inventory to update + * @property capacity the capacity of the inventory being transmitted in this + * update. + */ +public class UpdateInvFull private constructor( + public val combinedId: Int, + private val _inventoryId: UShort, + private val inventory: Inventory, +) : OutgoingGameMessage { + @Deprecated(message = "Interface Id/Component Id are no longer supported by the client, guaranteed crashing.") + public constructor( + interfaceId: Int, + componentId: Int, + inventoryId: Int, + capacity: Int, + provider: ObjectProvider, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + inventoryId.toUShort(), + buildInventory(capacity, provider), + ) + + public constructor( + combinedId: Int, + inventoryId: Int, + capacity: Int, + provider: ObjectProvider, + ) : this( + CombinedId(combinedId).combinedId, + inventoryId.toUShort(), + buildInventory(capacity, provider), + ) { + require(combinedId < 0) { + "Positive combined id will always lead to crashing as the client no longer supports it." + } + } + + public constructor( + inventoryId: Int, + capacity: Int, + provider: ObjectProvider, + ) : this( + -1, + inventoryId.toUShort(), + buildInventory(capacity, provider), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val inventoryId: Int + get() = _inventoryId.toInt() + public val capacity: Int + get() = inventory.count + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + // We always assume the obj counts are >= 255 + return Int.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + (capacity * 7) + } + + /** + * Gets the bitpacked obj in the [slot] provided. + * @param slot the slot in the inventory. + * @return the inventory object that's in that slot, + * or [InventoryObject.NULL] if there's no object. + * @throws IndexOutOfBoundsException if the [slot] is outside + * the inventory's boundaries. + */ + public fun getObject(slot: Int): Long = inventory[slot] + + public fun returnInventory() { + inventory.clear() + InventoryPool.pool.returnObject(inventory) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateInvFull + + if (combinedId != other.combinedId) return false + if (_inventoryId != other._inventoryId) return false + if (inventory != other.inventory) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _inventoryId.hashCode() + result = 31 * result + inventory.hashCode() + return result + } + + override fun toString(): String = + "UpdateInvFull(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "inventoryId=$inventoryId, " + + "capacity=$capacity" + + ")" + + /** + * An object provider interface is used to acquire the objs + * that exist in different inventories. These objs are bit-packed + * into a long, which gets further placed into a long array. + * This is all in order to avoid garbage creation with inventories, + * as this can be a considerable hot-spot for that. + */ + public fun interface ObjectProvider { + /** + * Provides an [InventoryObject] for a given slot + * in inventory. If there is no object in that slot, + * use [InventoryObject.NULL] as an indicator of it. + */ + public fun provide(slot: Int): Long + } + + private companion object { + /** + * Builds an inventory based on a [provider]. + * @param capacity the capacity of the inventory, this is how far + * the function will iterate to slots wise. + * @param provider the object provider, used to return information + * about an object in a slot of an inventory. + * @return an inventory object, which is a compressed representation + * of a list of [InventoryObject]s as longs, backed by a long array. + */ + private fun buildInventory( + capacity: Int, + provider: ObjectProvider, + ): Inventory { + val inventory = InventoryPool.pool.borrowObject() + for (i in 0..= 0) { + "Obj count cannot be below zero: $obj @ $i" + } + } + inventory.add(obj) + } + return inventory + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvPartial.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvPartial.kt new file mode 100644 index 000000000..22aaafae4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvPartial.kt @@ -0,0 +1,178 @@ +package net.rsprot.protocol.game.outgoing.inv + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.common.game.outgoing.inv.InventoryObject +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.internal.game.outgoing.inv.internal.Inventory +import net.rsprot.protocol.internal.game.outgoing.inv.internal.InventoryPool +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.util.CombinedId + +/** + * Update inv partial is used to send an update of an inventory after it has been + * initially synced up via [UpdateInvFull]. Every subsequent update is done via + * partial, until the [UpdateInvStopTransmit] packet happens, which resets the + * cycle. + * + * @property combinedId the combined id of the interface and the component id. + * For IF3-type interfaces, only negative values are allowed. + * If one wishes to make the inventory a "mirror", e.g. for trading, + * how both the player's own and the partner's inventory share the id, + * a value of < -70000 is expected, this tells the client that the respective + * inventory is a "mirrored" one. + * For normal IF3 interfaces, a value of -1 is perfectly acceptable. + * @property combinedId the bitpacked combination of [interfaceId] and [componentId]. + * @property interfaceId the IF1 interface on which the inventory lies. + * For IF3 interfaces, no [interfaceId] should be provided. + * @property componentId the component on which the inventory lies + * @property inventoryId the id of the inventory to update + * @property count the number of items added into this partial update. + */ +public class UpdateInvPartial private constructor( + public val combinedId: Int, + private val _inventoryId: UShort, + private val inventory: Inventory, +) : OutgoingGameMessage { + public constructor( + interfaceId: Int, + componentId: Int, + inventoryId: Int, + provider: IndexedObjectProvider, + ) : this( + CombinedId(interfaceId, componentId).combinedId, + inventoryId.toUShort(), + buildInventory(provider), + ) + + public constructor( + combinedId: Int, + inventoryId: Int, + provider: IndexedObjectProvider, + ) : this( + CombinedId(combinedId).combinedId, + inventoryId.toUShort(), + buildInventory(provider), + ) + + public constructor( + inventoryId: Int, + provider: IndexedObjectProvider, + ) : this( + -1, + inventoryId.toUShort(), + buildInventory(provider), + ) + + private val _combinedId: CombinedId + get() = CombinedId(combinedId) + public val interfaceId: Int + get() = _combinedId.interfaceId + public val componentId: Int + get() = _combinedId.componentId + public val inventoryId: Int + get() = _inventoryId.toInt() + public val count: Int + get() = inventory.count + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + // We always assume the worst case here, which would be + // 9 bytes per obj added + return Int.SIZE_BYTES + + Short.SIZE_BYTES + + (count * 9) + } + + /** + * Gets the bitpacked obj in the [slot] provided. + * @param slot the slot in the inventory. + * @return the inventory object that's in that slot, + * or [InventoryObject.NULL] if there's no object. + * @throws IndexOutOfBoundsException if the [slot] is outside + * the inventory's boundaries. + */ + public fun getObject(slot: Int): Long = inventory[slot] + + public fun returnInventory() { + inventory.clear() + InventoryPool.pool.returnObject(inventory) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateInvPartial + + if (combinedId != other.combinedId) return false + if (_inventoryId != other._inventoryId) return false + if (inventory != other.inventory) return false + + return true + } + + override fun hashCode(): Int { + var result = combinedId.hashCode() + result = 31 * result + _inventoryId.hashCode() + result = 31 * result + inventory.hashCode() + return result + } + + override fun toString(): String = + "UpdateInvPartial(" + + "interfaceId=$interfaceId, " + + "componentId=$componentId, " + + "inventoryId=$inventoryId, " + + "count=$count" + + ")" + + /** + * An object provider interface is used to acquire the objs + * that exist in different inventories. These objs are bit-packed + * into a long, which gets further placed into a long array. + * This is all in order to avoid garbage creation with inventories, + * as this can be a considerable hot-spot for that. + */ + public abstract class IndexedObjectProvider( + internal val indices: Iterator, + ) { + /** + * Provides an [InventoryObject] for a given slot + * in inventory. If there is no object in that slot, + * use [InventoryObject.NULL] as an indicator of it. + */ + public abstract fun provide(slot: Int): Long + } + + private companion object { + /** + * Builds an inventory based on a [provider]. + * @param provider the object provider, used to return information + * about an object in a slot of an inventory. + * @return an inventory object, which is a compressed representation + * of a list of [InventoryObject]s as longs, backed by a long array. + */ + private fun buildInventory(provider: IndexedObjectProvider): Inventory { + val inventory = InventoryPool.pool.borrowObject() + for (index in provider.indices) { + val obj = provider.provide(index) + if (RSProtFlags.inventoryObjCheck) { + check(obj != InventoryObject.NULL) { + "Obj cannot be InventoryObject.NULL for partial updates. Use InventoryObject(slot, -1, -1) " + + "instead." + } + check(InventoryObject.getSlot(obj) >= 0) { + "Obj slot cannot be below zero: $obj $ $index" + } + check(InventoryObject.getId(obj) == -1 || InventoryObject.getCount(obj) >= 0) { + "Obj count cannot be below zero: $obj @ $index" + } + } + inventory.add(obj) + } + return inventory + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvStopTransmit.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvStopTransmit.kt new file mode 100644 index 000000000..a855c4c77 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/inv/UpdateInvStopTransmit.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.inv + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update inv stop transmit is used by the server to inform the client + * that no more updates for a given inventory are expected. + * In OldSchool RuneScape, this is sent whenever an interface that's + * linked to the inventory is sent. + * In doing so, the client will wipe its cache of the given inventory. + * There is no technical reason to send this, however, as it doesn't + * prevent anything from functioning as normal. + * @property inventoryId the id of the inventory to stop listening to + */ +public class UpdateInvStopTransmit( + public val inventoryId: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateInvStopTransmit + + return inventoryId == other.inventoryId + } + + override fun hashCode(): Int = inventoryId + + override fun toString(): String = "UpdateInvStopTransmit(inventoryId=$inventoryId)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/Logout.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/Logout.kt new file mode 100644 index 000000000..09b1e7581 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/Logout.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.outgoing.logout + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Log out messages are used to tell the client the player + * has finished playing, which then causes the client to close + * the socket, and reset a lot of properties as a result. + */ +public data object Logout : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutTransfer.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutTransfer.kt new file mode 100644 index 000000000..badef7417 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutTransfer.kt @@ -0,0 +1,106 @@ +package net.rsprot.protocol.game.outgoing.logout + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Logout transfer packet is used for world-hopping purposes, + * making the client connect to a different world instead. + * + * World properties table: + * ``` + * | Flag | Type | + * |------------|:-----------------------:| + * | 0x1 | Members | + * | 0x2 | Quick chat | + * | 0x4 | PvP world | + * | 0x8 | Lootshare | + * | 0x10 | Dedicated activity | + * | 0x20 | Bounty world | + * | 0x40 | PvP Arena | + * | 0x80 | High level only - 1500+ | + * | 0x100 | Speedrun | + * | 0x200 | Existing players only | + * | 0x400 | Extra-hard wilderness | + * | 0x800 | Dungeoneering | + * | 0x1000 | Instance shard | + * | 0x2000 | Rentable | + * | 0x4000 | Last man standing | + * | 0x8000 | New players | + * | 0x10000 | Beta world | + * | 0x20000 | Staff IP only | + * | 0x40000 | High level only - 2000+ | + * | 0x80000 | High level only - 2400+ | + * | 0x100000 | VIPs only | + * | 0x200000 | Hidden world | + * | 0x400000 | Legacy only | + * | 0x800000 | EoC only | + * | 0x1000000 | Behind proxy | + * | 0x2000000 | No save mode | + * | 0x4000000 | Tournament world | + * | 0x8000000 | Fresh start world | + * | 0x10000000 | High level only - 1750+ | + * | 0x20000000 | Deadman world | + * | 0x40000000 | Seasonal world | + * | 0x80000000 | External partner only | + * ``` + * + * @property host the ip address of the new world + * @property id the id of the new world + * @property properties the flags of the new world + */ +public class LogoutTransfer private constructor( + public val host: String, + private val _id: UShort, + public val properties: Int, +) : OutgoingGameMessage { + public constructor( + host: String, + id: Int, + properties: Int, + ) : this( + host, + id.toUShort(), + properties, + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return Short.SIZE_BYTES + + Int.SIZE_BYTES + + estimateTextSize(host) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LogoutTransfer + + if (host != other.host) return false + if (_id != other._id) return false + if (properties != other.properties) return false + + return true + } + + override fun hashCode(): Int { + var result = host.hashCode() + result = 31 * result + _id.hashCode() + result = 31 * result + properties + return result + } + + override fun toString(): String = + "LogoutTransfer(" + + "host='$host', " + + "id=$id, " + + "properties=$properties" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutWithReason.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutWithReason.kt new file mode 100644 index 000000000..5e4466655 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/logout/LogoutWithReason.kt @@ -0,0 +1,41 @@ +package net.rsprot.protocol.game.outgoing.logout + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Logout with reason, much like [Logout], is used to + * log the player out of the game. The only difference here + * is that the user will be given a reason for why they were + * logged out of the game, e.g. inactive for too long. + * + * Logout reasons table: + * ``` + * | Id | Type | + * |----|:--------:| + * | 1 | Kicked | + * | 2 | Updating | + * ``` + * + * @property reason the id of the reason to display (see table above) + */ +public class LogoutWithReason( + public val reason: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LogoutWithReason + + return reason == other.reason + } + + override fun hashCode(): Int = reason + + override fun toString(): String = "LogoutWithReason(reason=$reason)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildLogin.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildLogin.kt new file mode 100644 index 000000000..ea5f12108 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildLogin.kt @@ -0,0 +1,121 @@ +package net.rsprot.protocol.game.outgoing.map + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfo +import net.rsprot.protocol.game.outgoing.map.util.XteaProvider +import net.rsprot.protocol.game.outgoing.map.util.buildXteaKeyList +import net.rsprot.protocol.message.ByteBufHolderWrapperFooterMessage + +/** + * Rebuild login is sent as part of the login procedure as the very first packet, + * as this one contains information about everyone's low resolution position, allowing + * the player information packet to be initialized properly. + * @property zoneX the x coordinate of the local player's current zone. + * @property zoneZ the z coordinate of the local player's current zone. + * @property worldArea the current world area in which the player resides. + * @property keys the list of xtea keys needed to decrypt the map. + * @property gpiInitBlock the initialization block of the player info protocol, + * used to inform the client of all the low resolution coordinates of everyone in the game. + */ +public class RebuildLogin private constructor( + private val _zoneX: UShort, + private val _zoneZ: UShort, + private val _worldArea: UShort, + override val keys: List, + public val gpiInitBlock: ByteBuf, +) : DefaultByteBufHolder(gpiInitBlock), + StaticRebuildMessage, + ByteBufHolderWrapperFooterMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + worldArea: Int, + keyProvider: XteaProvider, + playerInfo: PlayerInfo, + ) : this( + zoneX.toUShort(), + zoneZ.toUShort(), + worldArea.toUShort(), + buildXteaKeyList(zoneX, zoneZ, keyProvider), + initializePlayerInfo(playerInfo), + ) + + override val zoneX: Int + get() = _zoneX.toInt() + override val zoneZ: Int + get() = _zoneZ.toInt() + override val worldArea: Int + get() = _worldArea.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int = + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + (keys.size * (4 * Int.SIZE_BYTES)) + + gpiInitBlock.readableBytes() + + override fun nonByteBufHolderSize(): Int { + return Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + (keys.size * (4 * Int.SIZE_BYTES)) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RebuildLogin + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (_worldArea != other._worldArea) return false + if (keys != other.keys) return false + if (gpiInitBlock != other.gpiInitBlock) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + _worldArea.hashCode() + result = 31 * result + keys.hashCode() + result = 31 * result + gpiInitBlock.hashCode() + return result + } + + override fun toString(): String = + "RebuildLogin(" + + "keys=$keys, " + + "gpiInitBlock=$gpiInitBlock, " + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "worldArea=$worldArea" + + ")" + + private companion object { + private const val REBUILD_NORMAL_MAXIMUM_SIZE: Int = 44 + private const val PLAYER_INFO_BLOCK_SIZE = ((30 + (2046 * 18)) + Byte.SIZE_BITS - 1) ushr 3 + + /** + * Initializes the player info block into a buffer provided by allocator in the playerinfo object + * @param playerInfo the player info protocol of this player to be initialized + * @return a buffer containing the initialization block of the player info protocol + */ + private fun initializePlayerInfo(playerInfo: PlayerInfo): ByteBuf { + val allocator = playerInfo.allocator + val buffer = allocator.buffer(PLAYER_INFO_BLOCK_SIZE + REBUILD_NORMAL_MAXIMUM_SIZE) + playerInfo.handleAbsolutePlayerPositions(buffer) + return buffer + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildNormal.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildNormal.kt new file mode 100644 index 000000000..0e75d6918 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildNormal.kt @@ -0,0 +1,79 @@ +package net.rsprot.protocol.game.outgoing.map + +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.map.util.XteaProvider +import net.rsprot.protocol.game.outgoing.map.util.buildXteaKeyList + +/** + * Rebuild normal is sent when the game requires a map reload without being in instances. + * @property zoneX the x coordinate of the local player's current zone. + * @property zoneZ the z coordinate of the local player's current zone. + * @property worldArea the current world area in which the player resides. + * @property keys the list of xtea keys needed to decrypt the map. + */ +public class RebuildNormal private constructor( + private val _zoneX: UShort, + private val _zoneZ: UShort, + private val _worldArea: UShort, + override val keys: List, +) : StaticRebuildMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + worldArea: Int, + keyProvider: XteaProvider, + ) : this( + zoneX.toUShort(), + zoneZ.toUShort(), + worldArea.toUShort(), + buildXteaKeyList(zoneX, zoneZ, keyProvider), + ) + + override val zoneX: Int + get() = _zoneX.toInt() + override val zoneZ: Int + get() = _zoneZ.toInt() + override val worldArea: Int + get() = _worldArea.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int = + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Short.SIZE_BYTES + + (keys.size * (4 * Int.SIZE_BYTES)) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RebuildNormal + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (_worldArea != other._worldArea) return false + if (keys != other.keys) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + _worldArea.hashCode() + result = 31 * result + keys.hashCode() + return result + } + + override fun toString(): String = + "RebuildNormal(" + + "keys=$keys, " + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "worldArea=$worldArea" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildRegion.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildRegion.kt new file mode 100644 index 000000000..aaf3556a5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildRegion.kt @@ -0,0 +1,167 @@ +package net.rsprot.protocol.game.outgoing.map + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.map.util.RebuildRegionZone +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Rebuild region is used to send a dynamic map to the client, + * built up out of zones (8x8x1 tiles), allowing for any kind + * of unique instancing to occur. + * @property zoneX the x coordinate of the center zone around + * which the build area is built + * @property zoneZ the z coordinate of the center zone around + * which the build area is built + * @property reload whether to forcibly reload the map client-sided. + * If this property is false, the client will only reload if + * the last rebuild had difference [zoneX] or [zoneZ] coordinates + * than this one. + * @property zones the list of zones to build, in a specific order. + */ +public class RebuildRegion private constructor( + private val _zoneX: UShort, + private val _zoneZ: UShort, + public val reload: Boolean, + public val zones: List, +) : OutgoingGameMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + reload: Boolean, + zoneProvider: RebuildRegionZoneProvider, + ) : this( + zoneX.toUShort(), + zoneZ.toUShort(), + reload, + buildRebuildRegionZones( + zoneX, + zoneZ, + zoneProvider, + ), + ) + + public val zoneX: Int + get() = _zoneX.toInt() + public val zoneZ: Int + get() = _zoneZ.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + @Suppress("DuplicatedCode") + override fun estimateSize(): Int { + val header = + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + val notNullCount = zones.count { zone -> zone != null } + val bitCount = (27 * notNullCount) + (zones.size - notNullCount) + val bitBufByteCount = (bitCount + 7) ushr 3 + // While a little wasteful, it is expensive to determine the true + // number of bytes necessary since we only transmit xteas for + // each referenced mapsquare at most one time + // In here, we just assume each zone belongs in a unique mapsquare + // The buffers are pooled anyway so it isn't like we're typically + // allocating a ton here, just picking a larger buffer out of the pool. + val xteaSize = notNullCount * (4 * Int.SIZE_BYTES) + return header + + Short.SIZE_BYTES + + bitBufByteCount + + xteaSize + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RebuildRegion + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (reload != other.reload) return false + if (zones != other.zones) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + reload.hashCode() + result = 31 * result + zones.hashCode() + return result + } + + override fun toString(): String = + "RebuildRegion(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "reload=$reload, " + + "zones=$zones" + + ")" + + /** + * Zone provider acts as a function to provide all the necessary information + * needed for rebuild region to function, in the order the client + * expects it in. + */ + @JvmDefaultWithCompatibility + public fun interface RebuildRegionZoneProvider { + /** + * Provides a zone that the client must copy based on the parameters. + * In order to calculate the mapsquare id for xtea keys, use [getMapsquareId]. + * + * @param zoneX the x coordinate of the region zone + * @param zoneZ the z coordinate of the region zone + * @param level the level of the region zone + * @return the zone to be copied, or null if there's no zone to be copied there. + */ + public fun provide( + zoneX: Int, + zoneZ: Int, + level: Int, + ): RebuildRegionZone? + + /** + * Calculates the mapsquare id based on the zone coordinates. + * @param zoneX the x coordinate of the zone + * @param zoneZ the z coordinate of the zone + */ + public fun getMapsquareId( + zoneX: Int, + zoneZ: Int, + ): Int = (zoneX and 0x7FF ushr 3 shl 8) or (zoneZ and 0x7FF ushr 3) + } + + private companion object { + /** + * Builds a list of rebuild region zones to be written to the client, + * in order as the client expects them. + * @param centerZoneX the center zone x coordinate around which the build area is built + * @param centerZoneZ the center zone z coordinate around which the build area is built + * @param zoneProvider the functional interface providing the necessary information + * to be written to the client + * @return a list of rebuild region zones (or nulls) for each zone in the build area. + */ + private fun buildRebuildRegionZones( + centerZoneX: Int, + centerZoneZ: Int, + zoneProvider: RebuildRegionZoneProvider, + ): List { + val zones = ArrayList(4 * 13 * 13) + for (level in 0..<4) { + for (zoneX in (centerZoneX - 6)..(centerZoneX + 6)) { + for (zoneZ in (centerZoneZ - 6)..(centerZoneZ + 6)) { + zones += + zoneProvider.provide( + zoneX, + zoneZ, + level, + ) + } + } + } + return zones + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildTypealiases.kt new file mode 100644 index 000000000..8fbe6341c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildTypealiases.kt @@ -0,0 +1,9 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.map + +@Deprecated( + message = "Deprecated. Use RebuildWorldEntityV2.", + replaceWith = ReplaceWith("RebuildWorldEntityV2"), +) +public typealias RebuildWorldEntity = RebuildWorldEntityV2 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildWorldEntityV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildWorldEntityV2.kt new file mode 100644 index 000000000..51ef0adf5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/RebuildWorldEntityV2.kt @@ -0,0 +1,151 @@ +package net.rsprot.protocol.game.outgoing.map + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.map.util.RebuildRegionZone +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Rebuild worldentity packet is used to build a new world entity block, + * which will be rendered in the root world for the player. + * @property baseX the absolute base x coordinate of the world entity in the instance land + * @property baseZ the absolute base z coordinate of the world entity in the instance land + * @property zones the list of zones that will be built into the root world + */ +public class RebuildWorldEntityV2 private constructor( + private val _baseX: UShort, + private val _baseZ: UShort, + public val zones: List, +) : OutgoingGameMessage { + public constructor( + baseX: Int, + baseZ: Int, + sizeX: Int, + sizeZ: Int, + zoneProvider: RebuildWorldEntityZoneProvider, + ) : this( + baseX.toUShort(), + baseZ.toUShort(), + buildRebuildWorldEntityZones(sizeX, sizeZ, zoneProvider), + ) { + require(sizeX in 0..<13) { + "Size x must be in range of 0..<13: $sizeX" + } + require(sizeZ in 0..<13) { + "Size z must be in range of 0..<13: $sizeZ" + } + require(baseX in 0..<16384) { + "Base x must be in range of 0..<16384" + } + require(baseZ in 0..<16384) { + "Base z must be in range of 0..<16384" + } + } + + public val baseX: Int + get() = _baseX.toInt() + public val baseZ: Int + get() = _baseZ.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + @Suppress("DuplicatedCode") + override fun estimateSize(): Int { + val header = + Short.SIZE_BYTES + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + val notNullCount = zones.count { zone -> zone != null } + val bitCount = (27 * notNullCount) + (zones.size - notNullCount) + val bitBufByteCount = (bitCount + 7) ushr 3 + // While a little wasteful, it is expensive to determine the true + // number of bytes necessary since we only transmit xteas for + // each referenced mapsquare at most one time + // In here, we just assume each zone belongs in a unique mapsquare + // The buffers are pooled anyway so it isn't like we're typically + // allocating a ton here, just picking a larger buffer out of the pool. + val xteaSize = notNullCount * (4 * Int.SIZE_BYTES) + return header + + Short.SIZE_BYTES + + bitBufByteCount + + xteaSize + } + + override fun toString(): String { + return "RebuildWorldEntityV2(" + + "zones=$zones, " + + "baseX=$baseX, " + + "baseZ=$baseZ" + + ")" + } + + /** + * Zone provider acts as a function to provide all the necessary information + * needed for rebuild worldentity to function, in the order the client + * expects it in. + */ + @JvmDefaultWithCompatibility + public fun interface RebuildWorldEntityZoneProvider { + /** + * Provides a zone that the client must copy based on the parameters. + * This 'provide' function will be called with the relative-to-worldentity zone coordinates, + * so starting with 0,0 and ending before sizeX,sizeZ. The server is responsible for + * looking up the actual zone that was copied for that world entity. + * In order to calculate the mapsquare id for xtea keys, use [getMapsquareId]. + * + * @param zoneX the zone x coordinate of the region zone, relative to the south-westernmost zone + * @param zoneZ the zone z coordinate of the region zone, relative to the south-westernmost zone + * @param level the level of the region zone + * @return the zone to be copied, or null if there's no zone to be copied there. + */ + public fun provide( + zoneX: Int, + zoneZ: Int, + level: Int, + ): RebuildRegionZone? + + /** + * Calculates the mapsquare id based on the **absolute** zone coordinates, + * not the relative ones to the worldentity. + * @param zoneX the x coordinate of the zone + * @param zoneZ the z coordinate of the zone + */ + public fun getMapsquareId( + zoneX: Int, + zoneZ: Int, + ): Int = (zoneX and 0x7FF ushr 3 shl 8) or (zoneZ and 0x7FF ushr 3) + } + + private companion object { + /** + * Builds a list of rebuild region zones to be written to the client, + * in order as the client expects them. + * @param sizeX the width of the worldentity + * @param sizeZ the length of the worldentity + * @param zoneProvider the functional interface providing the necessary information + * to be written to the client + * @return a list of rebuild region zones (or nulls) for each zone in the build area. + */ + private fun buildRebuildWorldEntityZones( + sizeX: Int, + sizeZ: Int, + zoneProvider: RebuildWorldEntityZoneProvider, + ): List { + val zones = ArrayList(4 * sizeX * sizeZ) + for (level in 0..<4) { + for (zoneX in 0.. +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/RebuildRegionZone.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/RebuildRegionZone.kt new file mode 100644 index 000000000..8e689f535 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/RebuildRegionZone.kt @@ -0,0 +1,111 @@ +package net.rsprot.protocol.game.outgoing.map.util + +import net.rsprot.crypto.xtea.XteaKey + +/** + * This class wraps a reference zone to be copied together with the respective + * xtea key needed to decrypt the backing mapsquare. + * @property referenceZone the zone to be copied from the static map + * @property key the xtea key needed to decrypt the locs file in the cache of that respective mapsquare + */ +public class RebuildRegionZone public constructor( + public val referenceZone: ReferenceZone, + public val key: XteaKey, +) { + public constructor( + zoneX: Int, + zoneZ: Int, + level: Int, + rotation: Int, + key: XteaKey, + ) : this( + ReferenceZone( + zoneX, + zoneZ, + level, + rotation, + ), + key, + ) + + public val rotation: Int + get() = referenceZone.rotation + public val zoneX: Int + get() = referenceZone.zoneX + public val zoneZ: Int + get() = referenceZone.zoneZ + public val level: Int + get() = referenceZone.level + + public val mapsquareId: Int + get() = ((zoneX ushr 3) shl 8) or (zoneZ ushr 3) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RebuildRegionZone + + if (referenceZone != other.referenceZone) return false + if (key != other.key) return false + + return true + } + + override fun hashCode(): Int { + var result = referenceZone.hashCode() + result = 31 * result + key.hashCode() + return result + } + + override fun toString(): String = + "RebuildRegionZone(" + + "referenceZone=$referenceZone, " + + "key=$key" + + ")" + + /** + * A value class around zone objects that bitpacks all the properties into a single + * integer to be written to the client as the client expects it. + * @property rotation the rotation of the zone to be copied + * @property zoneX the x coordinate of the static zone to be copied + * @property zoneZ the z coordinate of the static zone to be copied + * @property level the level of the static zone to be copied + */ + @JvmInline + public value class ReferenceZone( + public val packed: Int, + ) { + public constructor( + zoneX: Int, + zoneZ: Int, + level: Int, + rotation: Int, + ) : this( + ((rotation and 0x3) shl 1) + .or((zoneZ and 0x7FF) shl 3) + .or((zoneX and 0x3FF) shl 14) + .or((level and 0x3) shl 24), + ) + + public val rotation: Int + get() = packed ushr 1 and 0x3 + public val zoneX: Int + get() = packed ushr 14 and 0x3FF + public val zoneZ: Int + get() = packed ushr 3 and 0x7FF + public val level: Int + get() = packed ushr 24 and 0x3 + + public val mapsquareId: Int + get() = ((zoneX ushr 3) shl 8) or (zoneZ ushr 3) + + override fun toString(): String = + "ReferenceZone(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "level=$level, " + + "rotation=$rotation" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaHelper.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaHelper.kt new file mode 100644 index 000000000..75224e890 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaHelper.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.map.util + +import net.rsprot.crypto.xtea.XteaKey + +/** + * A helper function to build the mapsquare key list the same way the client does, + * as the keys must be in the same specific order as the client reads it. + */ +internal fun buildXteaKeyList( + zoneX: Int, + zoneZ: Int, + keyProvider: XteaProvider, +): List { + val minMapsquareX = (zoneX - 6).coerceAtLeast(0) ushr 3 + val maxMapsquareX = (zoneX + 6).coerceAtMost(2047) ushr 3 + val minMapsquareZ = (zoneZ - 6).coerceAtLeast(0) ushr 3 + val maxMapsquareZ = (zoneZ + 6).coerceAtMost(2047) ushr 3 + val count = (maxMapsquareX - minMapsquareX + 1) * (maxMapsquareZ - minMapsquareZ + 1) + val keys = ArrayList(count.coerceIn(4, 9)) + for (mapsquareX in minMapsquareX..maxMapsquareX) { + for (mapsquareZ in minMapsquareZ..maxMapsquareZ) { + keys += keyProvider.provide((mapsquareX shl 8) or mapsquareZ) + } + } + return keys +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaProvider.kt new file mode 100644 index 000000000..75bc106ee --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/map/util/XteaProvider.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.game.outgoing.map.util + +import net.rsprot.crypto.xtea.XteaKey + +public fun interface XteaProvider { + public fun provide(mapsquareId: Int): XteaKey + + public companion object { + @JvmStatic + public val ZERO_XTEA_KEY_PROVIDER: XteaProvider = XteaProvider { XteaKey.ZERO } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideLocOps.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideLocOps.kt new file mode 100644 index 000000000..98ba4d34b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideLocOps.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Hide loc ops packet is used to hide the right-click menu of all locs across the game. + * @property hidden whether to hide all the click options of locs. + */ +public class HideLocOps( + public val hidden: Boolean, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HideLocOps + + return hidden == other.hidden + } + + override fun hashCode(): Int = hidden.hashCode() + + override fun toString(): String = "HideLocOps(hidden=$hidden)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideNpcOps.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideNpcOps.kt new file mode 100644 index 000000000..911786e4f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideNpcOps.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Hide npc ops packet is used to hide the right-click menu of all NPCs across the game. + * @property hidden whether to hide all the click options of NPCs. + */ +public class HideNpcOps( + public val hidden: Boolean, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HideNpcOps + + return hidden == other.hidden + } + + override fun hashCode(): Int = hidden.hashCode() + + override fun toString(): String = "HideNpcOps(hidden=$hidden)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideObjOps.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideObjOps.kt new file mode 100644 index 000000000..01a63972e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HideObjOps.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Hide obj ops packet is used to hide the right-click menu of all objs on the ground. + * @property hidden whether to hide all the click options of objs. + */ +public class HideObjOps( + public val hidden: Boolean, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HideObjOps + + return hidden == other.hidden + } + + override fun hashCode(): Int = hidden.hashCode() + + override fun toString(): String = "HideObjOps(hidden=$hidden)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HintArrow.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HintArrow.kt new file mode 100644 index 000000000..02027b3a9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HintArrow.kt @@ -0,0 +1,219 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Hint arrow packets are used to render a hint arrow + * at a specific player, NPC, Worldentity or a tile. + * Only a single hint arrow can exist at a time in OldSchool. + * @property type the hint arrow type to render. + */ +public class HintArrow( + public val type: HintArrowType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HintArrow + + return type == other.type + } + + override fun hashCode(): Int = type.hashCode() + + override fun toString(): String = "HintArrow(type=$type)" + + public sealed interface HintArrowType + + /** + * Reset hint arrow message is used to clear out any + * existing hint arrows. + */ + public data object ResetHintArrow : HintArrowType + + /** + * NPC hint arrows are used to render a hint arrow + * on-top of a specific NPC. + * @property index the index of the NPC who is receiving + * the hint arrow. Note that this is the real index without + * any offsets or additions. + */ + public class NpcHintArrow( + public val index: Int, + ) : HintArrowType { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcHintArrow + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "NpcHintArrow(index=$index)" + } + + /** + * Player hint arrows are used to render a hint arrow + * on-top of a specific player. + * @property index the index of the player who is receiving + * the hint arrow. Note that this is the real index without + * any offsets or additions. + */ + public class PlayerHintArrow( + public val index: Int, + ) : HintArrowType { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerHintArrow + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "PlayerHintArrow(index=$index)" + } + + /** + * Tile hint arrows are used to render a hint arrow at + * a specific coordinate. + * @property x the absolute x coordinate of the hint arrow. + * @property z the absolute z coordinate of the hint arrow. + * @property height the height of the hint arrow, + * with the expected range being 0 to 255 (inclusive). + * @property position the position of the hint arrow within + * the target tile. + */ + public class TileHintArrow private constructor( + private val _x: UShort, + private val _z: UShort, + private val _height: UByte, + private val _position: UByte, + ) : HintArrowType { + public constructor( + x: Int, + z: Int, + height: Int, + tilePosition: HintArrowTilePosition, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUByte(), + tilePosition.id.toUByte(), + ) + + public constructor( + x: Int, + z: Int, + height: Int, + tilePosition: Int, + ) : this( + x.toUShort(), + z.toUShort(), + height.toUByte(), + tilePosition.toUByte(), + ) + + public val x: Int + get() = _x.toInt() + public val z: Int + get() = _z.toInt() + public val height: Int + get() = _height.toInt() + public val position: HintArrowTilePosition + get() = HintArrowTilePosition[_position.toInt()] + public val positionId: Int + get() = _position.toInt() + + /** + * Hint arrow tile positions define where within a tile + * the given hint arrow will render. All the options here + * are centered on the tile, e.g. [WEST] will be at the + * western section of the tile, whilst being centered + * on the z-axis. + * + * @property id the id of the hint arrow position, + * as expected by the client. + */ + public enum class HintArrowTilePosition( + public val id: Int, + ) { + CENTER(2), + WEST(3), + EAST(4), + SOUTH(5), + NORTH(6), + ; + + internal companion object { + operator fun get(id: Int): HintArrowTilePosition = entries.first { it.id == id } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TileHintArrow + + if (_x != other._x) return false + if (_z != other._z) return false + if (_height != other._height) return false + if (_position != other._position) return false + + return true + } + + override fun hashCode(): Int { + var result = _x.hashCode() + result = 31 * result + _z.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _position.hashCode() + return result + } + + override fun toString(): String = + "TileHintArrow(" + + "x=$x, " + + "z=$z, " + + "height=$height, " + + "position=$position" + + ")" + } + + /** + * World entity hint arrows are used to render a hint arrow + * on-top of a specific world entity. + * @property index the index of the world entity in the root world to render the hint arrow on-top of. + * @property height the tile height value at which to render the world entity. + * Three bytes are read for it, meaning a value range of 0 to 16,777,215 could be transmitted. + */ + public class WorldEntityHintArrow( + public val index: Int, + public val height: Int, + ) : HintArrowType { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WorldEntityHintArrow + + return index == other.index + } + + override fun hashCode(): Int = index + + override fun toString(): String = "WorldEntityHintArrow(index=$index, height=$height)" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HiscoreReply.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HiscoreReply.kt new file mode 100644 index 000000000..f5db2cc86 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/HiscoreReply.kt @@ -0,0 +1,179 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Hiscore reply is a packet used in the enhanced clients to do + * lookups of nearby players, to find out their stats and rankings + * on the high scores. + * This packet is sent as a response to the hiscore request packet. + * @property requestId the id of the request that was made. + * @property response the response to be written to the client. + */ +public class HiscoreReply private constructor( + private val _requestId: UByte, + public val response: HiscoreReplyResponse, +) : OutgoingGameMessage { + public constructor( + requestId: Int, + response: HiscoreReplyResponse, + ) : this( + requestId.toUByte(), + response, + ) + + public val requestId: Int + get() = _requestId.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return when (response) { + is FailedHiscoreReply -> { + Byte.SIZE_BYTES + estimateTextSize(response.reason) + } + is SuccessfulHiscoreReply -> { + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + (response.statResults.size * (Short.SIZE_BYTES + Int.SIZE_BYTES + Int.SIZE_BYTES)) + + Int.SIZE_BYTES + + Long.SIZE_BYTES + + Short.SIZE_BYTES + + (response.activityResults.size * (Short.SIZE_BYTES + Int.SIZE_BYTES + Int.SIZE_BYTES)) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HiscoreReply + + if (_requestId != other._requestId) return false + if (response != other.response) return false + + return true + } + + override fun hashCode(): Int { + var result = _requestId.hashCode() + result = 31 * result + response.hashCode() + return result + } + + override fun toString(): String = + "HiscoreReply(" + + "requestId=$requestId, " + + "response=$response" + + ")" + + public sealed interface HiscoreReplyResponse + + /** + * A successful hiscore reply, transmitting all the stat and activity results. + * It is worth noting that because the packet isn't used, it is not entirely + * certain that the naming of these properties is accurate. These are merely + * a guess based on the hiscore json syntax. + * @property statResults the list of stats to transmit + * @property overallRank the overall rank of this player based on the total level + * @property overallExperience the overall experience of this player + * @property activityResults the list of activity results to transmit. + */ + public class SuccessfulHiscoreReply( + public val statResults: List, + public val overallRank: Int, + public val overallExperience: Long, + public val activityResults: List, + ) : HiscoreReplyResponse { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SuccessfulHiscoreReply + + if (statResults != other.statResults) return false + if (overallRank != other.overallRank) return false + if (overallExperience != other.overallExperience) return false + if (activityResults != other.activityResults) return false + + return true + } + + override fun hashCode(): Int { + var result = statResults.hashCode() + result = 31 * result + overallRank + result = 31 * result + overallExperience.hashCode() + result = 31 * result + activityResults.hashCode() + return result + } + + override fun toString(): String = + "SuccessfulHiscoreReply(" + + "statResults=$statResults, " + + "overallRank=$overallRank, " + + "overallExperience=$overallExperience, " + + "activityResults=$activityResults" + + ")" + } + + /** + * A failed hiscore reply would be sent when a lookup could not be + * performed successfully. The client will read a string for a reason + * when this occurs. + * @property reason the reason to give to the player for why + * the lookup could not be done. + */ + public class FailedHiscoreReply( + public val reason: String, + ) : HiscoreReplyResponse { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FailedHiscoreReply + + return reason == other.reason + } + + override fun hashCode(): Int = reason.hashCode() + + override fun toString(): String = "FailedHiscoreReply(reason='$reason')" + } + + public class HiscoreResult( + public val id: Int, + public val rank: Int, + public val result: Int, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HiscoreResult + + if (id != other.id) return false + if (rank != other.rank) return false + if (result != other.result) return false + + return true + } + + override fun hashCode(): Int { + var result1 = id + result1 = 31 * result1 + rank + result1 = 31 * result1 + result + return result1 + } + + override fun toString(): String = + "HiscoreResult(" + + "id=$id, " + + "rank=$rank, " + + "result=$result" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MinimapToggle.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MinimapToggle.kt new file mode 100644 index 000000000..f744a5765 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MinimapToggle.kt @@ -0,0 +1,43 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Minimap toggle is used to modify the state of the minimap + * and the attached compass. + * + * Minimap states table: + * ``` + * | Id | Description | + * |----|:-------------------------------:| + * | 0 | Enabled | + * | 1 | Minimap unclickable | + * | 2 | Minimap hidden | + * | 3 | Compass hidden | + * | 4 | Map unclickable, compass hidden | + * | 5 | Disabled | + * ``` + * + * @property minimapState the minimap state to set (see table above) + */ +public class MinimapToggle( + public val minimapState: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MinimapToggle + + return minimapState == other.minimapState + } + + override fun hashCode(): Int = minimapState + + override fun toString(): String = "MinimapToggle(minimapState=$minimapState)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MiscClientTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MiscClientTypealiases.kt new file mode 100644 index 000000000..5a39a4a0c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/MiscClientTypealiases.kt @@ -0,0 +1,9 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.misc.client + +@Deprecated( + message = "Deprecated. Use UpdateRebootTimerV2.", + replaceWith = ReplaceWith("UpdateRebootTimerV2"), +) +public typealias UpdateRebootTimer = UpdateRebootTimerV1 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/PacketGroupStart.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/PacketGroupStart.kt new file mode 100644 index 000000000..5fea46f63 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/PacketGroupStart.kt @@ -0,0 +1,58 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Packet group start is a packet which tells the client to wait until the entire + * payload of a packet group has arrived, then process all of it in a single client cycle, + * bypassing the usual 100 packets per client cycle limitation that the client has. + * @property messages the messages to wait for and process instantly. Note that the + * size of all these messages combined must be <= 32,767 bytes. Exceeding this limit + * will cause the protocol to crash for that user, disconnecting them. This is due to + * ISAAC cipher being modified during the encoding of the payload, which we cannot + * recover from without complex state tracking, which will not be supported. + */ +public class PacketGroupStart( + public val messages: List, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + // Always assume the highest possible size here, the buffers are pooled anyway. + // We cannot just sum up the estimations from [messages] as some packets are + // special and handled differently, which would cause unwanted resizing to occur + return 40_000 + } + + override fun markConsumed() { + for (message in messages) { + message.markConsumed() + } + } + + override fun safeRelease() { + for (message in messages) { + message.safeRelease() + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PacketGroupStart + + return messages == other.messages + } + + override fun hashCode(): Int { + return messages.hashCode() + } + + override fun toString(): String { + return "PacketGroupStart(messages=$messages)" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ReflectionChecker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ReflectionChecker.kt new file mode 100644 index 000000000..5191136c4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ReflectionChecker.kt @@ -0,0 +1,326 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Reflection checker packet will attempt to use [java.lang.reflect] to + * perform a lookup or invocation on a method or field in the client, + * using information provided in this packet. + * These invocations/lookups may fail completely, which is fully supported, + * as various exceptions get caught and special return codes are provided + * in such cases. + * An important thing to note, however, is that the server is responsible + * for not requesting too much, as the client's reply packet has a var-byte + * size, meaning the entire reply for a reflection check must fit into 255 + * bytes or fewer. There is no protection against this. + * Additionally worth noting that the [InvokeMethod] variant, while very + * powerful, is not utilized in OldSchool, and is rather dangerous to + * invoke due to the aforementioned size limitation. + * + * @property id the id of the reflection check, sent back in the reply and + * used to link together the request and reply, which is needed to fully + * decode the respective replies. + * @property checks the list of reflection checks to perform. + */ +public class ReflectionChecker( + public val id: Int, + public val checks: List, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + var size = Byte.SIZE_BYTES + Int.SIZE_BYTES + for (check in checks) { + when (check) { + is GetFieldValue -> { + size += Byte.SIZE_BYTES + + estimateTextSize(check.className) + + estimateTextSize(check.fieldName) + } + is SetFieldValue -> { + size += Byte.SIZE_BYTES + + estimateTextSize(check.className) + + estimateTextSize(check.fieldName) + + Int.SIZE_BYTES + } + is GetFieldModifiers -> { + size += Byte.SIZE_BYTES + + estimateTextSize(check.className) + + estimateTextSize(check.fieldName) + } + is InvokeMethod -> { + size += Byte.SIZE_BYTES + + estimateTextSize(check.className) + + estimateTextSize(check.methodName) + + val parameterClasses = check.parameterClasses + val parameterValues = check.parameterValues + size++ + for (parameterClass in parameterClasses) { + size += estimateTextSize(parameterClass) + } + size += estimateTextSize(check.returnClass) + for (parameterValue in parameterValues) { + size += Int.SIZE_BYTES + parameterValue.size + } + } + is GetMethodModifiers -> { + size += Int.SIZE_BYTES + + estimateTextSize(check.className) + + estimateTextSize(check.methodName) + + Byte.SIZE_BYTES + + estimateTextSize(check.returnClass) + val parameterClasses = check.parameterClasses + for (parameterClass in parameterClasses) { + size += estimateTextSize(parameterClass) + } + } + } + } + return size + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ReflectionChecker + + if (id != other.id) return false + if (checks != other.checks) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + checks.hashCode() + return result + } + + override fun toString(): String = + "ReflectionChecker(" + + "id=$id, " + + "checks=$checks" + + ")" + + public sealed interface ReflectionCheck + + /** + * Get field value is a reflection check which will aim to call the + * [java.lang.reflect.Field.getInt] function on the respective field. + * The value is submitted back in the reply, if a value was obtained. + * @property className the full class name in which the field exists. + * @property fieldName the name of the field in that class to look up. + */ + public class GetFieldValue( + public val className: String, + public val fieldName: String, + ) : ReflectionCheck { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetFieldValue + + if (className != other.className) return false + if (fieldName != other.fieldName) return false + + return true + } + + override fun hashCode(): Int { + var result = className.hashCode() + result = 31 * result + fieldName.hashCode() + return result + } + + override fun toString(): String = + "GetFieldValue(" + + "className='$className', " + + "fieldName='$fieldName'" + + ")" + } + + /** + * Set field value aims to try to assign the provided int [value] to + * a field in the class. + * @property className the full class name in which the field exists. + * @property fieldName the name of the field in that class to look up. + * @property value the value to try to assign to the field. + */ + public class SetFieldValue( + public val className: String, + public val fieldName: String, + public val value: Int, + ) : ReflectionCheck { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetFieldValue + + if (className != other.className) return false + if (fieldName != other.fieldName) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = className.hashCode() + result = 31 * result + fieldName.hashCode() + result = 31 * result + value + return result + } + + override fun toString(): String = + "SetFieldValue(" + + "className='$className', " + + "fieldName='$fieldName', " + + "value=$value" + + ")" + } + + /** + * Get field modifiers aims to try to look up a given field's modifiers, + * if possible. + * @property className the full class name in which the field exists. + * @property fieldName the name of the field in that class to look up. + */ + public class GetFieldModifiers( + public val className: String, + public val fieldName: String, + ) : ReflectionCheck { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetFieldModifiers + + if (className != other.className) return false + if (fieldName != other.fieldName) return false + + return true + } + + override fun hashCode(): Int { + var result = className.hashCode() + result = 31 * result + fieldName.hashCode() + return result + } + + override fun toString(): String = + "GetFieldModifiers(" + + "className='$className', " + + "fieldName='$fieldName'" + + ")" + } + + /** + * Invoke method check aims to try to invoke a function in a class + * with the provided parameters. The [parameterValues] are turned + * into an object using [java.io.ObjectInputStream.readObject] function. + * @property className the full name of the class in which the function lies. + * @property methodName the name of the function to invoke. + * @property parameterClasses the types of the parameters that the function takes. + * @property parameterValues the values to pass into the function, + * represented as a serialized byte array. + * @property returnClass the full name of the return type class + */ + public class InvokeMethod( + public val className: String, + public val methodName: String, + public val parameterClasses: List, + public val parameterValues: List, + public val returnClass: String, + ) : ReflectionCheck { + init { + require(parameterClasses.size == parameterValues.size) { + "Parameter classes and values must have an equal length: " + + "${parameterClasses.size}, ${parameterValues.size}" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as InvokeMethod + + if (className != other.className) return false + if (methodName != other.methodName) return false + if (parameterClasses != other.parameterClasses) return false + if (parameterValues != other.parameterValues) return false + if (returnClass != other.returnClass) return false + + return true + } + + override fun hashCode(): Int { + var result = className.hashCode() + result = 31 * result + methodName.hashCode() + result = 31 * result + parameterClasses.hashCode() + result = 31 * result + parameterValues.hashCode() + result = 31 * result + returnClass.hashCode() + return result + } + + override fun toString(): String = + "InvokeMethod(" + + "className='$className', " + + "methodName='$methodName', " + + "parameterClasses=$parameterClasses, " + + "parameterValues=$parameterValues, " + + "returnClass=$returnClass" + + ")" + } + + /** + * Get method modifiers will aim to try and look up a method's modifiers. + * @property className the full name of the class in which the function lies. + * @property methodName the name of the function to invoke. + * @property parameterClasses the types of the parameters that the function takes. + * @property returnClass the full name of the return type class + */ + public class GetMethodModifiers( + public val className: String, + public val methodName: String, + public val parameterClasses: List, + public val returnClass: String, + ) : ReflectionCheck { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GetMethodModifiers + + if (className != other.className) return false + if (methodName != other.methodName) return false + if (parameterClasses != other.parameterClasses) return false + if (returnClass != other.returnClass) return false + + return true + } + + override fun hashCode(): Int { + var result = className.hashCode() + result = 31 * result + methodName.hashCode() + result = 31 * result + parameterClasses.hashCode() + result = 31 * result + returnClass.hashCode() + return result + } + + override fun toString(): String = + "GetMethodModifiers(" + + "className='$className', " + + "methodName='$methodName', " + + "parameterClasses=$parameterClasses, " + + "returnClass=$returnClass" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetAnims.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetAnims.kt new file mode 100644 index 000000000..0d001538a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetAnims.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Reset anims message is used to reset the currently playing + * animation of all NPCs and players. This does not impact + * base animations (e.g. standing, walking). + * It is unclear what the purpose of this packet actually is. + */ +public data object ResetAnims : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetInteractionMode.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetInteractionMode.kt new file mode 100644 index 000000000..307fa0f0c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ResetInteractionMode.kt @@ -0,0 +1,39 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Resets the interaction mode for a specific world. + * @property worldId the id of the world to modify. + */ +public class ResetInteractionMode private constructor( + private val _worldId: Short, +) : OutgoingGameMessage { + public constructor(worldId: Int) : this(worldId.toShort()) + + public val worldId: Int + get() = _worldId.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResetInteractionMode) return false + + if (_worldId != other._worldId) return false + + return true + } + + override fun hashCode(): Int { + return _worldId.toInt() + } + + override fun toString(): String { + return "SetInteractionMode(" + + "worldId=$worldId" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SendPing.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SendPing.kt new file mode 100644 index 000000000..dd821be20 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SendPing.kt @@ -0,0 +1,47 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Send ping packet is used to request a ping response from the client. + * The client will send these [value1] and [value2] variables back to the + * server in exchange. + * These integer identifiers do not appear to have any known structure to + * them - they are not epoch time in any form. Seemingly random as the value + * can change drastically between different logins. + * @property value1 the first 32-bit integer identifier. + * @property value2 the second 32-bit integer identifier. + */ +public class SendPing( + public val value1: Int, + public val value2: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SendPing + + if (value1 != other.value1) return false + if (value2 != other.value2) return false + + return true + } + + override fun hashCode(): Int { + var result = value1 + result = 31 * result + value2 + return result + } + + override fun toString(): String = + "SendPing(" + + "value1=$value1, " + + "value2=$value2" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ServerTickEnd.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ServerTickEnd.kt new file mode 100644 index 000000000..ed9438ada --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ServerTickEnd.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Server tick end packets are used by the C++ client + * for ground item settings, in order to decrement + * visible ground item's timers. Without it, all ground + * items' timers will remain frozen once dropped. + */ +public data object ServerTickEnd : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetHeatmapEnabled.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetHeatmapEnabled.kt new file mode 100644 index 000000000..443791c4a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetHeatmapEnabled.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Set heatmap enabled packet is used to either enable or + * disabled the heatmap, which is rendered over the + * world map in OldSchool. + * This packet utilizes high resolution coordinate info + * about all the players of the game through player info + * packet, so in order for it to properly function, + * high resolution information must be sent for everyone + * in the game. + */ +public class SetHeatmapEnabled( + public val enabled: Boolean, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetHeatmapEnabled + + return enabled == other.enabled + } + + override fun hashCode(): Int = enabled.hashCode() + + override fun toString(): String = "SetHeatmapEnabled(enabled=$enabled)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetInteractionMode.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetInteractionMode.kt new file mode 100644 index 000000000..db2d1a51c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SetInteractionMode.kt @@ -0,0 +1,84 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Sets the interaction mode for a specific world. + * + * Tile interaction modes table: + * + * ```md + * | Id | Type | + * |:--:|:--------:| + * | 0 | Disabled | + * | 1 | Walk | + * | 2 | Heading | + * ``` + * + * Entity interaction modes table: + * + * ```md + * | Id | Type | + * |:--:|:------------:| + * | 0 | Disabled | + * | 1 | Enabled | + * | 2 | Examine Only | + * ``` + * + * @property worldId the id of the world to modify. If the value is -2, the default + * behaviour for all worlds is changed. + * @property tileInteractionMode sets the tile interaction mode. See the table above. + * @property entityInteractionMode sets the entity interaction mode. See the table above. + */ +public class SetInteractionMode private constructor( + private val _worldId: Short, + private val _tileInteractionMode: UByte, + private val _entityInteractionMode: UByte, +) : OutgoingGameMessage { + public constructor( + worldId: Int, + tileInteractionMode: Int, + entityInteractionMode: Int, + ) : this( + worldId.toShort(), + tileInteractionMode.toUByte(), + entityInteractionMode.toUByte(), + ) + + public val worldId: Int + get() = _worldId.toInt() + public val tileInteractionMode: Int + get() = _tileInteractionMode.toInt() + public val entityInteractionMode: Int + get() = _entityInteractionMode.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SetInteractionMode) return false + + if (_worldId != other._worldId) return false + if (_tileInteractionMode != other._tileInteractionMode) return false + if (_entityInteractionMode != other._entityInteractionMode) return false + + return true + } + + override fun hashCode(): Int { + var result = _worldId.toInt() + result = 31 * result + _tileInteractionMode.hashCode() + result = 31 * result + _entityInteractionMode.hashCode() + return result + } + + override fun toString(): String { + return "SetInteractionMode(" + + "worldId=$worldId, " + + "tileInteractionMode=$tileInteractionMode, " + + "entityInteractionMode=$entityInteractionMode" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SiteSettings.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SiteSettings.kt new file mode 100644 index 000000000..bc3ac325c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/SiteSettings.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Site settings packet is used to identify the given client. + * The settings are sent as part of the URL when connecting to services + * or secure RuneScape URLs. + * @property settings the settings string to assign + */ +public class SiteSettings( + public val settings: String, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return estimateTextSize(settings) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SiteSettings + + return settings == other.settings + } + + override fun hashCode(): Int = settings.hashCode() + + override fun toString(): String = "SiteSettings(settings='$settings')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV1.kt new file mode 100644 index 000000000..a5af7a3a7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV1.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update reboot timer is used to start the shut-down timer + * in preparation of an update. + * @property gameCycles the number of game cycles (600ms/gc) + * until the shut-down is complete. + * If the number is set to zero, any existing reboot timers + * will be cleared out. + * The maximum possible value is 65535, which is equal to just + * below 11 hours. + */ +public class UpdateRebootTimerV1( + public val gameCycles: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateRebootTimerV1 + + return gameCycles == other.gameCycles + } + + override fun hashCode(): Int = gameCycles + + override fun toString(): String = + "UpdateRebootTimerV1(" + + "gameCycles=$gameCycles" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV2.kt new file mode 100644 index 000000000..d3297a76f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateRebootTimerV2.kt @@ -0,0 +1,71 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update reboot timer is used to start the shut-down timer + * in preparation of an update, along with an optional message to show. + * @property gameCycles the number of game cycles (600ms/gc) + * until the shut-down is complete. + * If the number is set to zero, any existing reboot timers + * will be cleared out. + * The maximum possible value is 65535, which is equal to just + * below 11 hours. + */ +public class UpdateRebootTimerV2( + public val gameCycles: Int, + public val messageType: UpdateMessageType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + public sealed interface UpdateMessageType + + /** + * Sets the message to show alongside the update to the [message] provided. + * @property message the message to show to the players, alongside the count-down. + * Note that if the [message] is an empty string, the [IgnoreUpdateMessage] should be used instead, + * as the client skips modifying the update message variable in this scenario. + */ + public class SetUpdateMessage( + public val message: String, + ) : UpdateMessageType + + /** + * Sets the update timer and clears any existing update message being shown to the players. + */ + public data object ClearUpdateMessage : UpdateMessageType + + /** + * Sets the update timer and ignores any existing update messages - if one was previously set, + * the message would remain untouched. + */ + public data object IgnoreUpdateMessage : UpdateMessageType + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateRebootTimerV2 + + if (gameCycles != other.gameCycles) return false + if (messageType != other.messageType) return false + + return true + } + + override fun hashCode(): Int { + var result = gameCycles + result = 31 * result + messageType.hashCode() + return result + } + + override fun toString(): String { + return "UpdateRebootTimerV2(" + + "gameCycles=$gameCycles, " + + "messageType=$messageType" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateUid192.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateUid192.kt new file mode 100644 index 000000000..0a9521171 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UpdateUid192.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update UID 192 packed is used to update the random 192-bit + * id that is found in the random.dat file within the player's + * cache directory. + * The 192-bit UID will be accompanied by a 32-bit CRC of the + * block, which the client will verify before changing the + * contents of the random.dat file. + */ +public class UpdateUid192( + public val uid: ByteArray, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateUid192 + + return uid.contentEquals(other.uid) + } + + override fun hashCode(): Int = uid.contentHashCode() + + override fun toString(): String = "UpdateUid192(uid=${uid.contentToString()})" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UrlOpen.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UrlOpen.kt new file mode 100644 index 000000000..2084819f4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/UrlOpen.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * URL open packets are used to open a site on the target's default + * browser. + * @property url the url to connect to + */ +public class UrlOpen( + public val url: String, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return estimateTextSize(url) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UrlOpen + + return url == other.url + } + + override fun hashCode(): Int = url.hashCode() + + override fun toString(): String = "UrlOpen(url='$url')" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ZBuf.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ZBuf.kt new file mode 100644 index 000000000..a27d4455e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/client/ZBuf.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.misc.client + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * ZBuf packet is used to toggle depth buffering in client. + * @property enabled whether to enable depth buffering. + */ +public class ZBuf( + public val enabled: Boolean, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ZBuf + + return enabled == other.enabled + } + + override fun hashCode(): Int = enabled.hashCode() + + override fun toString(): String = "ZBuf(enabled=$enabled)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/AccountFlags.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/AccountFlags.kt new file mode 100644 index 000000000..4df0d8113 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/AccountFlags.kt @@ -0,0 +1,38 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Account flags are used to set certain features in the client for given players. + * + * Below is a table of known flags: + * + * ``` + * | Bit | Feature | + * |-----|----------------------------------------| + * | 35 | Enable Lua Plugin Development Commands | + * ``` + */ +public class AccountFlags( + public val flags: Long, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AccountFlags + + return flags == other.flags + } + + override fun hashCode(): Int { + return flags.hashCode() + } + + override fun toString(): String = "AccountFlags(flags=$flags)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettings.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettings.kt new file mode 100644 index 000000000..21dfcd0f9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettings.kt @@ -0,0 +1,69 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Chat filter settings packed is used to set the public and + * trade chat filters to the specified values. + * + * Chat filters table: + * ``` + * | Id | Type | + * |----|:--------:| + * | 0 | On | + * | 1 | Friends | + * | 2 | Off | + * | 3 | Hide | + * | 4 | Autochat | + * ``` + * + * @property publicChatFilter the public chat filter value, allowed values + * include everything in the table above. + * @property tradeChatFilter the trade chat filter value, allowed values include + * 'On', 'Friends' and 'Off' (see table above) + */ +public class ChatFilterSettings private constructor( + private val _publicChatFilter: UByte, + private val _tradeChatFilter: UByte, +) : OutgoingGameMessage { + public constructor( + publicChatFilter: Int, + tradeChatFilter: Int, + ) : this( + publicChatFilter.toUByte(), + tradeChatFilter.toUByte(), + ) + + public val publicChatFilter: Int + get() = _publicChatFilter.toInt() + public val tradeChatFilter: Int + get() = _tradeChatFilter.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChatFilterSettings + + if (_publicChatFilter != other._publicChatFilter) return false + if (_tradeChatFilter != other._tradeChatFilter) return false + + return true + } + + override fun hashCode(): Int { + var result = _publicChatFilter.hashCode() + result = 31 * result + _tradeChatFilter.hashCode() + return result + } + + override fun toString(): String = + "ChatFilterSettings(" + + "publicChatFilter=$publicChatFilter, " + + "tradeChatFilter=$tradeChatFilter" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettingsPrivateChat.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettingsPrivateChat.kt new file mode 100644 index 000000000..02c2e2b3b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/ChatFilterSettingsPrivateChat.kt @@ -0,0 +1,40 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Chat filter settings packed is used to set the private + * chat filter. + * + * Chat filters table: + * ``` + * | Id | Type | + * |----|:--------:| + * | 0 | On | + * | 1 | Friends | + * | 2 | Off | + * ``` + * + * @property privateChatFilter the private chat filter value. + */ +public class ChatFilterSettingsPrivateChat( + public val privateChatFilter: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChatFilterSettingsPrivateChat + + return privateChatFilter == other.privateChatFilter + } + + override fun hashCode(): Int = privateChatFilter + + override fun toString(): String = "ChatFilterSettingsPrivateChat(privateChatFilter=$privateChatFilter)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MessageGame.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MessageGame.kt new file mode 100644 index 000000000..37861a598 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MessageGame.kt @@ -0,0 +1,127 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Message game packet is used to send a normal game message in + * the player's chatbox. + * + * Game message types (note: names without asterisk are official from a leak): + * ``` + * | Id | Type | + * |-----|:----------------------------------:| + * | 0 | chattype_gamemessage | + * | 1 | chattype_modchat | + * | 2 | chattype_publicchat | + * | 3 | chattype_privatechat | + * | 4 | chattype_engine | + * | 5 | chattype_loginlogoutnotification | + * | 6 | chattype_privatechatout | + * | 7 | chattype_modprivatechat | + * | 9 | chattype_friendschat | + * | 11 | chattype_friendschatnotification | + * | 14 | chattype_broadcast | + * | 26 | chattype_snapshotfeedback | + * | 27 | chattype_obj_examine | + * | 28 | chattype_npc_examine | + * | 29 | chattype_loc_examine | + * | 30 | chattype_friendnotification | + * | 31 | chattype_ignorenotification | + * | 41 | chattype_clan* | + * | 43 | chattype_clan_system* | + * | 44 | chattype_clan_guest* | + * | 46 | chattype_clan_guest_system* | + * | 90 | chattype_autotyper | + * | 91 | chattype_modautotyper | + * | 99 | chattype_console | + * | 101 | chattype_tradereq | + * | 102 | chattype_trade | + * | 103 | chattype_chalreq_trade | + * | 104 | chattype_chalreq_friendschat | + * | 105 | chattype_spam | + * | 106 | chattype_playerrelated | + * | 107 | chattype_10sectimeout | + * | 108 | chattype_welcome* | + * | 109 | chattype_clan_creation_invitation* | + * | 110 | chattype_clan_wars_challenge* | + * | 111 | chattype_gim_form_group* | + * | 112 | chattype_gim_group_with* | + * ``` + * + * @property type the type of the message to send (see table above) + * @property name the name of the target player who is making a request. + * This property is only for messages such as "X wishes to trade with you.", + * where there is a player at the other end that is making some sort of request. + * Upon interacting with these chat messages, the client will invoke the respective + * op-player packet if it can find that player in local player's high resolution + * list of players. + * It is important to note, however, that only opplayer 1, 4, 6 and 7 will ever + * be fired in this manner. + * @property message the message itself to render in the chatbox + */ +public class MessageGame private constructor( + private val _type: UShort, + public val name: String?, + public val message: String, +) : OutgoingGameMessage { + public constructor( + type: Int, + name: String?, + message: String, + ) : this( + type.toUShort(), + name, + message, + ) + + public constructor( + type: Int, + message: String, + ) : this( + type.toUShort(), + null, + message, + ) + + public val type: Int + get() = _type.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int { + return (if (type >= 0x80) Short.SIZE_BYTES else Byte.SIZE_BYTES) + + Byte.SIZE_BYTES + + (if (name != null) estimateTextSize(name) else 0) + + estimateTextSize(message) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageGame + + if (_type != other._type) return false + if (name != other.name) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = _type.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessageGame(" + + "type=$type, " + + "name=$name, " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MiscPlayerTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MiscPlayerTypealiases.kt new file mode 100644 index 000000000..f4b673d75 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/MiscPlayerTypealiases.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +@Deprecated( + message = "Deprecated. Use UpdateStatV2.", + replaceWith = ReplaceWith("UpdateStatV2"), +) +public typealias UpdateStat = UpdateStatV2 + +@Deprecated( + message = "Deprecated. Use SetMapFlagV2.", + replaceWith = ReplaceWith("SetMapFlagV2"), +) +public typealias SetMapFlag = SetMapFlagV1 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/RunClientScript.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/RunClientScript.kt new file mode 100644 index 000000000..8e898d760 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/RunClientScript.kt @@ -0,0 +1,170 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.RSProtFlags +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Run clientscript packet is used to execute a clientscript in the client + * with the provided arguments. + * @property id the id of the script to invoke + * @property types the array of characters representing the clientscript types + * to send to the client. + * The supported types are IntArray('W'), Array('X'), String('s') and Int ('i'). + * Note that IntArray(int[] in Java) specifically must be passed in, + * and not an Array(Integer[] in Java). Latter will throw an exception. + * Any char code not in the aforementioned list will be treated as an int. + * @property values the list of values to be sent in the client script. + */ +public class RunClientScript : OutgoingGameMessage { + public val id: Int + public val types: CharArray + public val values: List + + /** + * A primary constructor that allows ones to pass in the types from the server, in case one wishes + * to provide accurate types. The client however only cares whether the type is a string, or isn't a string, + * and the exact type values get discarded. + */ + public constructor( + id: Int, + types: CharArray, + values: List, + ) { + this.id = id + this.types = types + this.values = values + if (RSProtFlags.clientscriptVerification) { + require(types.size == values.size) { + "Types and values sizes must match: ${types.size}, ${values.size}" + } + for (i in types.indices) { + val type = types[i] + val value = values[i] + when (type) { + 'W' -> { + require(value is IntArray) { + "Expected IntArray(int[] in Java) value at index $i for char $type, got: $value" + } + } + 'X' -> { + require(value is Array<*> && value.isArrayOf()) { + "Expected Array(String[] in Java) value at index $i for char $type, got: $value" + } + } + 's' -> { + require(value is String) { + "Expected string value at index $i for char $type, got: $value" + } + } + else -> { + require(value is Int) { + "Expected int value at index $i for char $type, got: $value" + } + } + } + } + } + } + + /** + * A secondary constructor that allows one to only pass in the values and infer the types from the + * values. All values must be IntArray, Array, Int or String types, and may be mixed. + * As client discards the actual types, there's no value in providing the exact type values to the + * client, and we can simply infer this the same way client reverses it. + */ + public constructor( + id: Int, + values: List, + ) { + this.id = id + this.values = values + this.types = + CharArray(values.size) { index -> + when (val value = values[index]) { + is IntArray -> 'W' + is Array<*> -> 'X' + is Int -> 'i' + is String -> 's' + else -> throw IllegalArgumentException( + "Unknown clientscript value type: " + + "${value.javaClass} @ $value, " + + "accepted types only include IntArray, Array, Int and String.", + ) + } + } + } + + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + var payloadSize = 0 + // For clientscripts, as they can be so volatile, + // we calculate an accurate length of the message based on highest possible values. + for (i in (types.size - 1) downTo 0) { + val type = types[i] + when (type) { + 'W' -> { + payloadSize += VAR_INT_2_SIZE_ESTIMATE + val element = values[i] as IntArray + payloadSize += element.size * VAR_INT_2_SIZE_ESTIMATE + } + 'X' -> { + payloadSize += VAR_INT_2_SIZE_ESTIMATE + val elements = values[i] as Array<*> + for (element in elements) { + payloadSize += estimateTextSize(element as String) + } + } + 's' -> { + payloadSize += estimateTextSize(values[i] as String) + } + else -> { + payloadSize += Int.SIZE_BYTES + } + } + } + return types.size + + Byte.SIZE_BYTES + + payloadSize + + Int.SIZE_BYTES + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RunClientScript + + if (id != other.id) return false + if (!types.contentEquals(other.types)) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + types.contentHashCode() + result = 31 * result + values.hashCode() + return result + } + + override fun toString(): String = + "RunClientScript(" + + "id=$id, " + + "types=${types.contentToString()}, " + + "values=$values" + + ")" + + private companion object { + /** + * A size estimate in bytes for VarInt2. Since the value is dynamic, we can only assume it + * will be the highest possible size, which is 5 bytes. + */ + private const val VAR_INT_2_SIZE_ESTIMATE: Int = 5 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV1.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV1.kt new file mode 100644 index 000000000..672d84488 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV1.kt @@ -0,0 +1,49 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Set map flag is used to set the red map flag on the minimap. + * Use values 255, 255 to remove the map flag. + * @property xInBuildArea the x coordinate within the build area + * to render the map flag at. + * @property zInBuildArea the z coordinate within the build area + * to render the map flag at. + */ +public class SetMapFlagV1 private constructor( + private val coordInBuildArea: CoordInBuildArea, +) : OutgoingGameMessage { + public constructor( + xInBuildArea: Int, + zInBuildArea: Int, + ) : this( + CoordInBuildArea(xInBuildArea, zInBuildArea), + ) + + public val xInBuildArea: Int + get() = coordInBuildArea.xInBuildArea + public val zInBuildArea: Int + get() = coordInBuildArea.zInBuildArea + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetMapFlagV1 + + return coordInBuildArea == other.coordInBuildArea + } + + override fun hashCode(): Int = coordInBuildArea.hashCode() + + override fun toString(): String = + "SetMapFlagV1(" + + "xInBuildArea=$xInBuildArea, " + + "zInBuildArea=$zInBuildArea" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV2.kt new file mode 100644 index 000000000..53c9b418a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetMapFlagV2.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Set map flag is used to set the red map flag on the minimap. Note that the [CoordGrid.level] property + * is unused in this packet. + * Use the no-arguments constructor to clear the map flag, or refer to [RESET] in the companion. + * @property coordGrid the coord grid to show the map flag at. + */ +public class SetMapFlagV2 private constructor( + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + x: Int, + z: Int, + ) : this( + CoordGrid(0, x, z), + ) + + public constructor() : this(CoordGrid.INVALID) + + public val x: Int + get() = coordGrid.x + public val z: Int + get() = coordGrid.z + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetMapFlagV2 + + return coordGrid == other.coordGrid + } + + override fun hashCode(): Int { + return coordGrid.hashCode() + } + + override fun toString(): String { + return "SetMapFlagV2(" + + "x=$x, " + + "z=$z" + + ")" + } + + public companion object { + @JvmStatic + public val RESET: SetMapFlagV2 = SetMapFlagV2() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetPlayerOp.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetPlayerOp.kt new file mode 100644 index 000000000..67a741372 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/SetPlayerOp.kt @@ -0,0 +1,69 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Set player op packet is used to set the right-click + * option on all players to a specific option. + * @property id the id of the option to change, a value in range of + * 1 to 8 (inclusive) + * @property priority whether the option should get priority + * over the 'Walk here' option. + * @property op the option string to set, or null if removing an op. + */ +public class SetPlayerOp private constructor( + private val _id: UByte, + public val priority: Boolean, + public val op: String?, +) : OutgoingGameMessage { + public constructor( + id: Int, + priority: Boolean, + op: String?, + ) : this( + id.toUByte(), + priority, + op, + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + estimateTextSize(op ?: "null") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetPlayerOp + + if (_id != other._id) return false + if (priority != other.priority) return false + if (op != other.op) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + priority.hashCode() + result = 31 * result + op.hashCode() + return result + } + + override fun toString(): String = + "SetPlayerOp(" + + "id=$id, " + + "priority=$priority, " + + "op='$op'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/TriggerOnDialogAbort.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/TriggerOnDialogAbort.kt new file mode 100644 index 000000000..1977bbdb7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/TriggerOnDialogAbort.kt @@ -0,0 +1,15 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Trigger on dialog abort is used to invoke any ondialogabort + * scripts that have been set up on interfaces, typically to close + * any dialogues. + */ +public data object TriggerOnDialogAbort : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunEnergy.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunEnergy.kt new file mode 100644 index 000000000..10b5aef5b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunEnergy.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update runenergy packet is used to modify the player's current + * run energy. 100 units equals one percentage on the run orb, + * meaning a value of 10,000 is equal to 100% run energy. + */ +public class UpdateRunEnergy( + public val runenergy: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateRunEnergy + + return runenergy == other.runenergy + } + + override fun hashCode(): Int = runenergy + + override fun toString(): String = "UpdateRunEnergy(runenergy=$runenergy)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunWeight.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunWeight.kt new file mode 100644 index 000000000..87bd69152 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateRunWeight.kt @@ -0,0 +1,29 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update runweight packet is used to modify the player's current + * equipment and inventory weight, in kilograms. + */ +public class UpdateRunWeight( + public val runweight: Int, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateRunWeight + + return runweight == other.runweight + } + + override fun hashCode(): Int = runweight + + override fun toString(): String = "UpdateRunWeight(runweight=$runweight)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStatV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStatV2.kt new file mode 100644 index 000000000..f74117fd4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStatV2.kt @@ -0,0 +1,74 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update stat packet is used to set the current experience + * and levels of a skill for a given player. + * @property stat the id of the stat to update + * @property currentLevel player's current level in that stat, + * e.g. boosted or drained. + * @property invisibleBoostedLevel player's level in the stat + * with invisible boosts included + * @property experience player's experience in the skill, + * in its integer form - expected value range 0 to 200,000,000. + */ +public class UpdateStatV2 private constructor( + private val _stat: UByte, + private val _currentLevel: UByte, + private val _invisibleBoostedLevel: UByte, + public val experience: Int, +) : OutgoingGameMessage { + public constructor( + stat: Int, + currentLevel: Int, + invisibleBoostedLevel: Int, + experience: Int, + ) : this( + stat.toUByte(), + currentLevel.toUByte(), + invisibleBoostedLevel.toUByte(), + experience, + ) + + public val stat: Int + get() = _stat.toInt() + public val currentLevel: Int + get() = _currentLevel.toInt() + public val invisibleBoostedLevel: Int + get() = _invisibleBoostedLevel.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateStatV2 + + if (_stat != other._stat) return false + if (_currentLevel != other._currentLevel) return false + if (_invisibleBoostedLevel != other._invisibleBoostedLevel) return false + if (experience != other.experience) return false + + return true + } + + override fun hashCode(): Int { + var result = _stat.hashCode() + result = 31 * result + _currentLevel.hashCode() + result = 31 * result + _invisibleBoostedLevel.hashCode() + result = 31 * result + experience + return result + } + + override fun toString(): String = + "UpdateStatV2(" + + "stat=$stat, " + + "currentLevel=$currentLevel, " + + "invisibleBoostedLevel=$invisibleBoostedLevel, " + + "experience=$experience" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStockMarketSlot.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStockMarketSlot.kt new file mode 100644 index 000000000..5d4d1233b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateStockMarketSlot.kt @@ -0,0 +1,130 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update stockmarket slot packet is used to set up + * an offer on the Grand Exchange, or to clear out an + * offer. + * @property update the update type to perform, either + * [ResetStockMarketSlot] or [SetStockMarketSlot]. + */ +public class UpdateStockMarketSlot private constructor( + private val _slot: UByte, + public val update: StockMarketUpdateType, +) : OutgoingGameMessage { + public constructor( + slot: Int, + update: StockMarketUpdateType, + ) : this( + slot.toUByte(), + update, + ) + + public val slot: Int + get() = _slot.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateStockMarketSlot + + return update == other.update + } + + override fun hashCode(): Int = update.hashCode() + + override fun toString(): String = + "UpdateStockMarketSlot(" + + "slot=$slot, " + + "update=$update" + + ")" + + public sealed interface StockMarketUpdateType + + public data object ResetStockMarketSlot : StockMarketUpdateType + + /** + * Set stockmarket slot update creates an offer + * on the Grand Exchange. + * @property status the status of the offer to create. + * Note that if the status value is 0, it will be treated + * as a request to clear out the slot and all the remaining + * data will be ignored in the process. + * @property obj the obj to set in the specified slot + * @property price the price per item + * @property count the count to buy or sell + * @property completedCount the amount already bought or sold + * @property completedGold the amount of gold received + */ + public class SetStockMarketSlot private constructor( + private val _status: Byte, + private val _obj: UShort, + public val price: Int, + public val count: Int, + public val completedCount: Int, + public val completedGold: Int, + ) : StockMarketUpdateType { + public constructor( + status: Int, + obj: Int, + price: Int, + count: Int, + completedCount: Int, + completedGold: Int, + ) : this( + status.toByte(), + obj.toUShort(), + price, + count, + completedCount, + completedGold, + ) + + public val status: Int + get() = _status.toInt() + public val obj: Int + get() = _obj.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetStockMarketSlot + + if (_status != other._status) return false + if (_obj != other._obj) return false + if (price != other.price) return false + if (count != other.count) return false + if (completedCount != other.completedCount) return false + if (completedGold != other.completedGold) return false + + return true + } + + override fun hashCode(): Int { + var result = _status.toInt() + result = 31 * result + _obj.hashCode() + result = 31 * result + price + result = 31 * result + count + result = 31 * result + completedCount + result = 31 * result + completedGold + return result + } + + override fun toString(): String = + "SetStockMarketSlot(" + + "status=$status, " + + "obj=$obj, " + + "price=$price, " + + "count=$count, " + + "completedCount=$completedCount, " + + "completedGold=$completedGold" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateTradingPost.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateTradingPost.kt new file mode 100644 index 000000000..62f182c5a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/misc/player/UpdateTradingPost.kt @@ -0,0 +1,177 @@ +package net.rsprot.protocol.game.outgoing.misc.player + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update trading post packed was used to create + * a list of offers of a specific obj in the trading + * post interface back when it still existed, in circa + * 2014. This packet has not had a use since then, however. + */ +public class UpdateTradingPost( + public val updateType: TradingPostUpdateType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return when (updateType) { + ResetTradingPost -> Byte.SIZE_BYTES + is SetTradingPostOfferList -> { + val sizePerOffer = + 13 + + 13 + + Short.SIZE_BYTES + + Long.SIZE_BYTES + + Int.SIZE_BYTES + + Int.SIZE_BYTES + + Byte.SIZE_BYTES + + Long.SIZE_BYTES + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + + Short.SIZE_BYTES + + (updateType.offers.size * sizePerOffer) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateTradingPost + + return updateType == other.updateType + } + + override fun hashCode(): Int = updateType.hashCode() + + override fun toString(): String = "UpdateTradingPost(updateType=$updateType)" + + public sealed interface TradingPostUpdateType + + public data object ResetTradingPost : TradingPostUpdateType + + public class SetTradingPostOfferList private constructor( + public val age: Long, + private val _obj: UShort, + public val status: Boolean, + public val offers: List, + ) : TradingPostUpdateType { + public constructor( + age: Long, + obj: Int, + status: Boolean, + offers: List, + ) : this( + age, + obj.toUShort(), + status, + offers, + ) { + require(offers.size <= 65535) { + "Offers size must fit in an unsigned short" + } + } + + public val obj: Int + get() = _obj.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetTradingPostOfferList + + if (age != other.age) return false + if (_obj != other._obj) return false + if (status != other.status) return false + if (offers != other.offers) return false + + return true + } + + override fun hashCode(): Int { + var result = age.hashCode() + result = 31 * result + _obj.hashCode() + result = 31 * result + status.hashCode() + result = 31 * result + offers.hashCode() + return result + } + + override fun toString(): String = + "SetTradingPostOfferList(" + + "age=$age, " + + "obj=$obj, " + + "status=$status, " + + "offers=$offers" + + ")" + } + + public class TradingPostOffer private constructor( + public val name: String, + public val previousName: String, + private val _world: UShort, + public val time: Long, + public val price: Int, + public val count: Int, + ) { + public constructor( + name: String, + previousName: String, + world: Int, + time: Long, + price: Int, + count: Int, + ) : this( + name, + previousName, + world.toUShort(), + time, + price, + count, + ) + + public val world: Int + get() = _world.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TradingPostOffer + + if (name != other.name) return false + if (previousName != other.previousName) return false + if (_world != other._world) return false + if (time != other.time) return false + if (price != other.price) return false + if (count != other.count) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + previousName.hashCode() + result = 31 * result + _world.hashCode() + result = 31 * result + time.hashCode() + result = 31 * result + price + result = 31 * result + count + return result + } + + override fun toString(): String = + "TradingPostOffer(" + + "name='$name', " + + "previousName='$previousName', " + + "world=$world, " + + "time=$time, " + + "price=$price, " + + "count=$count" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/FriendListLoaded.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/FriendListLoaded.kt new file mode 100644 index 000000000..0c49ace6e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/FriendListLoaded.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.game.outgoing.social + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Friend list loaded is used to mark the friend list + * as loaded if there are no friends to be sent. + * If there are friends to be sent, use the [UpdateFriendList] + * packet instead without this. + */ +public data object FriendListLoaded : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivate.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivate.kt new file mode 100644 index 000000000..f007f3a0b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivate.kt @@ -0,0 +1,107 @@ +package net.rsprot.protocol.game.outgoing.social + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateHuffmanEncodedTextSize +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Message private packets are used to send private messages between + * players across multiple worlds. + * This specific packet results in the `From name: message` being shown + * on the target's client. + * @property sender name of the player who is sending the message + * @property worldId the id of the world from which the message is sent + * @property worldMessageCounter the world-local message counter. + * Each world must have its own message counter which is used to create + * a unique id for each message. This message counter must be + * incrementing with each message that is sent out. + * If two messages share the same unique id (which is a combination of + * the [worldId] and the [worldMessageCounter] properties), + * the client will not render the second message if it already has one + * received in the last 100 messages. + * It is additionally worth noting that servers with low population + * should probably not start the counter at the same value with each + * game boot, as the probability of multiple messages coinciding + * is relatively high in that scenario, given the low quantity of + * messages sent out to begin with. + * Additionally, only the first 24 bits of the counter are utilized, + * meaning a value from 0 to 16,777,215 (inclusive). + * A good starting point for message counting would be to take the + * hour of the year and multiply it by 50,000 when the server boots + * up. This means the roll-over happens roughly after every two weeks. + * Fine-tuning may be used to make it more granular, but the overall + * idea remains the same. + * @property chatCrownType the id of the crown to render next to the + * name of the sender. + * @property message the message to be forwarded to the recipient. + */ +public class MessagePrivate private constructor( + public val sender: String, + private val _worldId: UShort, + public val worldMessageCounter: Int, + private val _chatCrownType: UByte, + public val message: String, +) : OutgoingGameMessage { + public constructor( + sender: String, + worldId: Int, + worldMessageCounter: Int, + chatCrownType: Int, + message: String, + ) : this( + sender, + worldId.toUShort(), + worldMessageCounter, + chatCrownType.toUByte(), + message, + ) + + public val worldId: Int + get() = _worldId.toInt() + public val chatCrownType: Int + get() = _chatCrownType.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int = + estimateTextSize(sender) + + Short.SIZE_BYTES + + 3 + + Byte.SIZE_BYTES + + estimateHuffmanEncodedTextSize(message) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessagePrivate + + if (sender != other.sender) return false + if (_worldId != other._worldId) return false + if (worldMessageCounter != other.worldMessageCounter) return false + if (_chatCrownType != other._chatCrownType) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = sender.hashCode() + result = 31 * result + _worldId.hashCode() + result = 31 * result + worldMessageCounter + result = 31 * result + _chatCrownType.hashCode() + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessagePrivate(" + + "sender='$sender', " + + "worldId=$worldId, " + + "worldMessageCounter=$worldMessageCounter, " + + "chatCrownType=$chatCrownType, " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivateEcho.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivateEcho.kt new file mode 100644 index 000000000..2a14dd388 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/MessagePrivateEcho.kt @@ -0,0 +1,52 @@ +package net.rsprot.protocol.game.outgoing.social + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.util.estimateHuffmanEncodedTextSize +import net.rsprot.protocol.message.util.estimateTextSize + +/** + * Message private echo is used to show the messages + * the given player has sent out to others, + * in a "To name: message" format. + * @property recipient the name of the player who received + * the private message. + * @property message the message to be forwarded. + */ +public class MessagePrivateEcho( + public val recipient: String, + public val message: String, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + return estimateTextSize(recipient) + + estimateHuffmanEncodedTextSize(message) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessagePrivateEcho + + if (recipient != other.recipient) return false + if (message != other.message) return false + + return true + } + + override fun hashCode(): Int { + var result = recipient.hashCode() + result = 31 * result + message.hashCode() + return result + } + + override fun toString(): String = + "MessagePrivateEcho(" + + "recipient='$recipient', " + + "message='$message'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateFriendList.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateFriendList.kt new file mode 100644 index 000000000..9380e77f7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateFriendList.kt @@ -0,0 +1,282 @@ +package net.rsprot.protocol.game.outgoing.social + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update friendlist is used to send the initial friend list on login, + * as well as any additions to the friend list over time. + * @property friends the list of friends to be added/set to this friend list. + * For instances of this class, use [OnlineFriend] and [OfflineFriend] + * respectively. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class UpdateFriendList( + public val friends: List, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + // In here we assume max len names (and previous names) + // but 0 length world name/notes + // Even if the world name or notes are used, it would probably + // still fall below the allocated size due to the excess allocated + // through the name strings + val sizePerFriend = + Byte.SIZE_BYTES + + 13 + + 13 + + Short.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Int.SIZE_BYTES + + Byte.SIZE_BYTES + return friends.size * sizePerFriend + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateFriendList + + return friends == other.friends + } + + override fun hashCode(): Int = friends.hashCode() + + override fun toString(): String = "UpdateFriendList(friends=$friends)" + + public sealed interface Friend { + public val added: Boolean + public val name: String + public val previousName: String? + public val worldId: Int + public val rank: Int + public val properties: Int + public val notes: String + } + + /** + * Online friends are friends who are currently logged into the game. + * @property added whether the friend was just added to the friend list, + * or if it's an initial load. For initial loads, the client skips existing + * friend checks. + * @property name the display name of the friend + * @property previousName the previous display name of the friend, + * if they had one. If not, set it to null. + * @property worldId the world that the friend is logged into + * @property rank the friend's current rank, used to determine the chat icon + * @property properties a set of bitpacked properties; currently, the client + * only checks for two properties - [PROPERTY_REFERRED] and [PROPERTY_REFERRER]. + * These properties only affect the ordering of friends in the player's friend list. + * @property notes the notes on that friend. None of the clients use this value. + * @property worldName the name of the world the player is logged into, + * e.g. "Old School 35" for world 335 in OldSchool RuneScape. + * @property platform the id of the client the friend is logged into. + * Current known values include 0 for RuneScape 3, 4 for RS3's lobby (presumably), + * and 8 for OldSchool RuneScape. The OldSchool clients do not utilize this, + * its purpose is to prevent sending quick-chat messages from RuneScape 3 over + * to OldSchool RuneScape, as it does not support quick chat functionality. + * @property worldFlags the flags of the world the friend is logged into. + */ + public class OnlineFriend private constructor( + override val added: Boolean, + override val name: String, + override val previousName: String?, + private val _worldId: UShort, + private val _rank: UByte, + private val _properties: UByte, + override val notes: String, + public val worldName: String, + private val _platform: UByte, + public val worldFlags: Int, + ) : Friend { + public constructor( + added: Boolean, + name: String, + previousName: String?, + worldId: Int, + rank: Int, + properties: Int, + notes: String, + worldName: String, + platform: Int, + worldFlags: Int, + ) : this( + added, + name, + previousName, + worldId.toUShort(), + rank.toUByte(), + properties.toUByte(), + notes, + worldName, + platform.toUByte(), + worldFlags, + ) { + require(worldId > 0) { + "World id must be greater than 0 for online friends" + } + } + + override val worldId: Int + get() = _worldId.toInt() + override val rank: Int + get() = _rank.toInt() + override val properties: Int + get() = _properties.toInt() + public val platform: Int + get() = _platform.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OnlineFriend + + if (added != other.added) return false + if (name != other.name) return false + if (previousName != other.previousName) return false + if (_worldId != other._worldId) return false + if (_rank != other._rank) return false + if (_properties != other._properties) return false + if (notes != other.notes) return false + if (worldName != other.worldName) return false + if (_platform != other._platform) return false + if (worldFlags != other.worldFlags) return false + + return true + } + + override fun hashCode(): Int { + var result = added.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + (previousName?.hashCode() ?: 0) + result = 31 * result + _worldId.hashCode() + result = 31 * result + _rank.hashCode() + result = 31 * result + _properties.hashCode() + result = 31 * result + notes.hashCode() + result = 31 * result + worldName.hashCode() + result = 31 * result + _platform.hashCode() + result = 31 * result + worldFlags + return result + } + + override fun toString(): String = + "OnlineFriend(" + + "added=$added, " + + "name='$name', " + + "previousName=$previousName, " + + "worldId=$worldId, " + + "rank=$rank, " + + "properties=$properties, " + + "worldName='$worldName', " + + "platform=$platform, " + + "worldFlags=$worldFlags, " + + "notes='$notes'" + + ")" + } + + /** + * Offline friends are friends who either aren't logged in, or cannot be + * seen as online due to preferences chosen. + * @property added whether the friend was just added to the friend list, + * or if it's an initial load. For initial loads, the client skips existing + * friend checks. + * @property name the display name of the friend + * @property previousName the previous display name of the friend, + * if they had one. If not, set it to null. + * @property worldId the world that the friend is logged into + * @property rank the friend's current rank, used to determine the chat icon + * @property properties a set of bitpacked properties; currently, the client + * only checks for two properties - [PROPERTY_REFERRED] and [PROPERTY_REFERRER]. + * These properties only affect the ordering of friends in the player's friend list. + * @property notes the notes on that friend. None of the clients use this value. + */ + public class OfflineFriend private constructor( + override val added: Boolean, + override val name: String, + override val previousName: String?, + private val _rank: UByte, + private val _properties: UByte, + override val notes: String, + ) : Friend { + public constructor( + added: Boolean, + name: String, + previousName: String?, + rank: Int, + properties: Int, + notes: String, + ) : this( + added, + name, + previousName, + rank.toUByte(), + properties.toUByte(), + notes, + ) + + override val rank: Int + get() = _rank.toInt() + override val properties: Int + get() = _properties.toInt() + override val worldId: Int + get() = 0 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OfflineFriend + + if (added != other.added) return false + if (name != other.name) return false + if (previousName != other.previousName) return false + if (_rank != other._rank) return false + if (_properties != other._properties) return false + if (notes != other.notes) return false + + return true + } + + override fun hashCode(): Int { + var result = added.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + (previousName?.hashCode() ?: 0) + result = 31 * result + _rank.hashCode() + result = 31 * result + _properties.hashCode() + result = 31 * result + notes.hashCode() + return result + } + + override fun toString(): String = + "OfflineFriend(" + + "added=$added, " + + "name='$name', " + + "previousName=$previousName, " + + "rank=$rank, " + + "properties=$properties, " + + "notes='$notes'" + + ")" + } + + public companion object { + /** + * Referred property is used to assign a higher priority to a friend + * in the friend list. + */ + public const val PROPERTY_REFERRED: Int = 0x1 + + /** + * Referred property is used to assign a higher priority to a friend + * in the friend list. Referrers have a higher priority than [PROPERTY_REFERRED]. + */ + public const val PROPERTY_REFERRER: Int = 0x2 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateIgnoreList.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateIgnoreList.kt new file mode 100644 index 000000000..3f9ac03f5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/social/UpdateIgnoreList.kt @@ -0,0 +1,117 @@ +package net.rsprot.protocol.game.outgoing.social + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update ignorelist is used to perform changes to the ignore list. + * Unlike friend list, it is possible to delete ignore list entries + * from the server's perspective. + * @property ignores the list of ignores to add or remove. + */ +public class UpdateIgnoreList( + public val ignores: List, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun estimateSize(): Int { + // Assume max name/previous name length, and no notes. + val sizePerIgnore = + Byte.SIZE_BYTES + + 13 + + 13 + + Byte.SIZE_BYTES + return ignores.size * sizePerIgnore + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateIgnoreList + + return ignores == other.ignores + } + + override fun hashCode(): Int = ignores.hashCode() + + override fun toString(): String = "UpdateIgnoreList(ignores=$ignores)" + + public sealed interface IgnoredPlayer { + public val name: String + } + + /** + * Removed ignored entry is an ignored entry that is requested to be + * deleted from the ignore list of this player. + * @property name the name of the ignored player + */ + public class RemovedIgnoredEntry( + override val name: String, + ) : IgnoredPlayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RemovedIgnoredEntry + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "RemovedIgnoredEntry(name='$name')" + } + + /** + * Added ignore entry encompasses all the ignore list entries + * which are added to the ignore list, be that during login or + * individual additions of new entries. + * @property name the name of the player to be added to the ignore list + * @property previousName the previous name of that player, if they had any. + * Set to null if there is no previous name associated. + * @property note the note attached to this player. + * This property is not used in any of the OldSchool RuneScape clients. + * @property added whether the ignore list entry was just added, or if it's + * a historic entry sent during login. + * If the property is false, the client skips any existing name checks. + */ + public class AddedIgnoredEntry( + override val name: String, + public val previousName: String?, + public val note: String, + public val added: Boolean, + ) : IgnoredPlayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddedIgnoredEntry + + if (name != other.name) return false + if (previousName != other.previousName) return false + if (note != other.note) return false + if (added != other.added) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + (previousName?.hashCode() ?: 0) + result = 31 * result + note.hashCode() + result = 31 * result + added.hashCode() + return result + } + + override fun toString(): String = + "AddedIgnoredEntry(" + + "name='$name', " + + "previousName=$previousName, " + + "note='$note', " + + "added=$added" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiJingle.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiJingle.kt new file mode 100644 index 000000000..ea4a8470f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiJingle.kt @@ -0,0 +1,65 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Midi jingle packet is used to play a short midi song, typically when + * the player accomplishes something. The normal song that was playing + * will be resumed after the jingle finishes playing. + * In the old days, the [lengthInMillis] property was used to tell the client + * how long the jingle lasts, so it knows when to resume the normal midi song. + * It has long since been removed, however - while the client expects a 24-bit + * integer for the length, it does not use this value in any way. + * @property id the id of the jingle to play + * @property lengthInMillis the length in milliseconds of the jingle, now unused. + */ +public class MidiJingle private constructor( + private val _id: UShort, + public val lengthInMillis: Int, +) : OutgoingGameMessage { + public constructor( + id: Int, + ) : this( + id.toUShort(), + 0, + ) + + public constructor( + id: Int, + lengthInMillis: Int, + ) : this( + id.toUShort(), + lengthInMillis, + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MidiJingle + + if (_id != other._id) return false + if (lengthInMillis != other.lengthInMillis) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + lengthInMillis + return result + } + + override fun toString(): String = + "MidiJingle(" + + "id=$id, " + + "lengthInMillis=$lengthInMillis" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongStop.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongStop.kt new file mode 100644 index 000000000..620731a78 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongStop.kt @@ -0,0 +1,54 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Midi song stop is used to stop playing an existing midi song. + * @property fadeOutDelay the delay in client cycles (20ms/cc) until the song begins fading out. + * @property fadeOutSpeed the speed at which the song fades out in client cycles (20ms/cc). + */ +public class MidiSongStop private constructor( + private val _fadeOutDelay: UShort, + private val _fadeOutSpeed: UShort, +) : OutgoingGameMessage { + public constructor( + fadeOutDelay: Int, + fadeOutSpeed: Int, + ) : this( + fadeOutDelay.toUShort(), + fadeOutSpeed.toUShort(), + ) + + public val fadeOutDelay: Int + get() = _fadeOutDelay.toInt() + public val fadeOutSpeed: Int + get() = _fadeOutSpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MidiSongStop + + if (_fadeOutDelay != other._fadeOutDelay) return false + if (_fadeOutSpeed != other._fadeOutSpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = _fadeOutDelay.hashCode() + result = 31 * result + _fadeOutSpeed.hashCode() + return result + } + + override fun toString(): String = + "MidiSongStop(" + + "fadeOutDelay=$fadeOutDelay, " + + "fadeOutSpeed=$fadeOutSpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongV2.kt new file mode 100644 index 000000000..9c3075606 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongV2.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Midi song packets are used to play songs through the music player. + * @property id the id of the midi song + * @property fadeOutDelay the delay in client cycles (20ms/cc) until the old song + * begins fading out. The default value for this, based on the old midi song packet, is 0. + * @property fadeOutSpeed the speed at which the old song fades out in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet, is 60. + * @property fadeInDelay the delay until the new song begins playing, in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet is 60. + * @property fadeInSpeed the speed at which the new song fades in, in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet is 0. + */ +@Suppress("DuplicatedCode") +public class MidiSongV2 private constructor( + private val _id: UShort, + private val _fadeOutDelay: UShort, + private val _fadeOutSpeed: UShort, + private val _fadeInDelay: UShort, + private val _fadeInSpeed: UShort, +) : OutgoingGameMessage { + public constructor( + id: Int, + fadeOutDelay: Int, + fadeOutSpeed: Int, + fadeInDelay: Int, + fadeInSpeed: Int, + ) : this( + id.toUShort(), + fadeOutDelay.toUShort(), + fadeOutSpeed.toUShort(), + fadeInDelay.toUShort(), + fadeInSpeed.toUShort(), + ) + + public val id: Int + get() = _id.toInt() + public val fadeOutDelay: Int + get() = _fadeOutDelay.toInt() + public val fadeOutSpeed: Int + get() = _fadeOutSpeed.toInt() + public val fadeInDelay: Int + get() = _fadeInDelay.toInt() + public val fadeInSpeed: Int + get() = _fadeInSpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MidiSongV2 + + if (_id != other._id) return false + if (_fadeOutDelay != other._fadeOutDelay) return false + if (_fadeOutSpeed != other._fadeOutSpeed) return false + if (_fadeInDelay != other._fadeInDelay) return false + if (_fadeInSpeed != other._fadeInSpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _fadeOutDelay.hashCode() + result = 31 * result + _fadeOutSpeed.hashCode() + result = 31 * result + _fadeInDelay.hashCode() + result = 31 * result + _fadeInSpeed.hashCode() + return result + } + + override fun toString(): String = + "MidiSongV2(" + + "id=$id, " + + "fadeOutDelay=$fadeOutDelay, " + + "fadeOutSpeed=$fadeOutSpeed, " + + "fadeInDelay=$fadeInDelay, " + + "fadeInSpeed=$fadeInSpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongWithSecondary.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongWithSecondary.kt new file mode 100644 index 000000000..a3fdd7fb4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSongWithSecondary.kt @@ -0,0 +1,100 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Midi song packets are used to play songs through the music player. + * This packet pre-queues a secondary song which can be hot-swapped at any point. + * The intended use case here is to swap the song out mid-playing between identical + * songs that have different tones playing, e.g. a more up-beat vs a more somber song, + * while letting the song play on from where it was, rather than re-starting the song. + * @property primaryId the primary id of the song that will be playing + * @property secondaryId the secondary id that will play if the `MIDI_SWAP` packet + * is sent. + * @property fadeOutDelay the delay in client cycles (20ms/cc) until the old song + * begins fading out. The default value for this, based on the old midi song packet, is 0. + * @property fadeOutSpeed the speed at which the old song fades out in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet, is 60. + * @property fadeInDelay the delay until the new song begins playing, in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet is 60. + * @property fadeInSpeed the speed at which the new song fades in, in client cycles (20ms/cc). + * The default value for this, based on the old midi song packet is 0. + */ +@Suppress("DuplicatedCode") +public class MidiSongWithSecondary private constructor( + private val _primaryId: UShort, + private val _secondaryId: UShort, + private val _fadeOutDelay: UShort, + private val _fadeOutSpeed: UShort, + private val _fadeInDelay: UShort, + private val _fadeInSpeed: UShort, +) : OutgoingGameMessage { + public constructor( + primaryId: Int, + secondaryId: Int, + fadeOutDelay: Int, + fadeOutSpeed: Int, + fadeInDelay: Int, + fadeInSpeed: Int, + ) : this( + primaryId.toUShort(), + secondaryId.toUShort(), + fadeOutDelay.toUShort(), + fadeOutSpeed.toUShort(), + fadeInDelay.toUShort(), + fadeInSpeed.toUShort(), + ) + + public val primaryId: Int + get() = _primaryId.toInt() + public val secondaryId: Int + get() = _secondaryId.toInt() + public val fadeOutDelay: Int + get() = _fadeOutDelay.toInt() + public val fadeOutSpeed: Int + get() = _fadeOutSpeed.toInt() + public val fadeInDelay: Int + get() = _fadeInDelay.toInt() + public val fadeInSpeed: Int + get() = _fadeInSpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MidiSongWithSecondary + + if (_primaryId != other._primaryId) return false + if (_secondaryId != other._secondaryId) return false + if (_fadeOutDelay != other._fadeOutDelay) return false + if (_fadeOutSpeed != other._fadeOutSpeed) return false + if (_fadeInDelay != other._fadeInDelay) return false + if (_fadeInSpeed != other._fadeInSpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = _primaryId.hashCode() + result = 31 * result + _secondaryId.hashCode() + result = 31 * result + _fadeOutDelay.hashCode() + result = 31 * result + _fadeOutSpeed.hashCode() + result = 31 * result + _fadeInDelay.hashCode() + result = 31 * result + _fadeInSpeed.hashCode() + return result + } + + override fun toString(): String = + "MidiSongWithSecondary(" + + "primaryId=$primaryId, " + + "secondaryId=$secondaryId, " + + "fadeOutSpeed=$fadeOutSpeed, " + + "fadeOutDelay=$fadeOutDelay, " + + "fadeInDelay=$fadeInDelay, " + + "fadeInSpeed=$fadeInSpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSwap.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSwap.kt new file mode 100644 index 000000000..c1bf42ebd --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/MidiSwap.kt @@ -0,0 +1,76 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Midi swap packet allows one to hot-swap a song mid-playing with a different one + * that was pre-queued with the [MidiSongWithSecondary] packet. + * This hot-swapping only works if the secondary packet was used, as that defines + * the id of the secondary song to swap to. + * @property fadeOutDelay the delay in client cycles (20ms/cc) until the old song + * begins fading out. + * @property fadeOutSpeed the speed at which the old song fades out in client cycles (20ms/cc). + * @property fadeInDelay the delay until the new song begins playing, in client cycles (20ms/cc). + * @property fadeInSpeed the speed at which the new song fades in, in client cycles (20ms/cc). + */ +public class MidiSwap private constructor( + private val _fadeOutDelay: UShort, + private val _fadeOutSpeed: UShort, + private val _fadeInDelay: UShort, + private val _fadeInSpeed: UShort, +) : OutgoingGameMessage { + public constructor( + fadeOutDelay: Int, + fadeOutSpeed: Int, + fadeInDelay: Int, + fadeInSpeed: Int, + ) : this( + fadeOutDelay.toUShort(), + fadeOutSpeed.toUShort(), + fadeInDelay.toUShort(), + fadeInSpeed.toUShort(), + ) + + public val fadeOutDelay: Int + get() = _fadeOutDelay.toInt() + public val fadeOutSpeed: Int + get() = _fadeOutSpeed.toInt() + public val fadeInDelay: Int + get() = _fadeInDelay.toInt() + public val fadeInSpeed: Int + get() = _fadeInSpeed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MidiSwap + + if (_fadeOutDelay != other._fadeOutDelay) return false + if (_fadeOutSpeed != other._fadeOutSpeed) return false + if (_fadeInDelay != other._fadeInDelay) return false + if (_fadeInSpeed != other._fadeInSpeed) return false + + return true + } + + override fun hashCode(): Int { + var result = _fadeOutDelay.hashCode() + result = 31 * result + _fadeOutSpeed.hashCode() + result = 31 * result + _fadeInDelay.hashCode() + result = 31 * result + _fadeInSpeed.hashCode() + return result + } + + override fun toString(): String = + "MidiSwap(" + + "fadeOutDelay=$fadeOutDelay, " + + "fadeInDelay=$fadeInDelay, " + + "fadeInSpeed=$fadeInSpeed, " + + "fadeOutSpeed=$fadeOutSpeed" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SoundTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SoundTypealiases.kt new file mode 100644 index 000000000..0304199d4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SoundTypealiases.kt @@ -0,0 +1,9 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.sound + +@Deprecated( + message = "Deprecated. Use MidiSongV2.", + replaceWith = ReplaceWith("MidiSongV2"), +) +public typealias MidiSong = MidiSongV2 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SynthSound.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SynthSound.kt new file mode 100644 index 000000000..49975c72c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/sound/SynthSound.kt @@ -0,0 +1,63 @@ +package net.rsprot.protocol.game.outgoing.sound + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Synth sound is used to play a short sound effect locally for the given player. + * @property id the id of the sound effect to play + * @property loops the number of times to loop the sound effect + * @property delay the delay in client cycles (20ms/cc) until the sound effect begins playing + */ +public class SynthSound private constructor( + private val _id: UShort, + private val _loops: UByte, + private val _delay: UShort, +) : OutgoingGameMessage { + public constructor( + id: Int, + loops: Int, + delay: Int, + ) : this( + id.toUShort(), + loops.toUByte(), + delay.toUShort(), + ) + + public val id: Int + get() = _id.toInt() + public val loops: Int + get() = _loops.toInt() + public val delay: Int + get() = _delay.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SynthSound + + if (_id != other._id) return false + if (_loops != other._loops) return false + if (_delay != other._delay) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _loops.hashCode() + result = 31 * result + _delay.hashCode() + return result + } + + override fun toString(): String = + "SynthSound(" + + "id=$id, " + + "loops=$loops, " + + "delay=$delay" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/LocAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/LocAnimSpecific.kt new file mode 100644 index 000000000..32768d869 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/LocAnimSpecific.kt @@ -0,0 +1,123 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.game.outgoing.zone.payload.util.LocProperties +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Loc anim specific packets are used to make a loc play an animation, + * specific to one player and not the entire world. + * @property id the id of the animation to play + * @property zoneX the x coordinate of the zone's south-western corner in the + * build area. + * @property xInZone the x coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zoneZ the z coordinate of the zone's south-western corner in the + * build area. + * @property zInZone the z coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property shape the shape of the loc, a value of 0 to 22 (inclusive) is expected. + * @property rotation the rotation of the loc, a value of 0 to 3 (inclusive) is expected. + * + * It should be noted that the [zoneX] and [zoneZ] coordinates are relative + * to the build area in their absolute form, not in their shifted zone form. + * If the player is at an absolute coordinate of 50, 40 within the build area(104x104), + * the expected coordinates to transmit here would be 48, 40, as that would + * point to the south-western corner of the zone in which the player is standing in. + * The client will add up the respective [zoneX] + [xInZone] properties together, + * along with [zoneZ] + [zInZone] to re-create the effects of a normal zone packet. + */ +public class LocAnimSpecific private constructor( + private val _id: UShort, + private val coordInBuildArea: CoordInBuildArea, + private val locProperties: LocProperties, +) : OutgoingGameMessage { + public constructor( + id: Int, + zoneX: Int, + xInZone: Int, + zoneZ: Int, + zInZone: Int, + shape: Int, + rotation: Int, + ) : this( + id.toUShort(), + CoordInBuildArea( + zoneX, + xInZone, + zoneZ, + zInZone, + ), + LocProperties(shape, rotation), + ) + + public constructor( + id: Int, + xInBuildArea: Int, + zInBuildArea: Int, + shape: Int, + rotation: Int, + ) : this( + id.toUShort(), + CoordInBuildArea( + xInBuildArea, + zInBuildArea, + ), + LocProperties(shape, rotation), + ) + + public val id: Int + get() = _id.toInt() + public val zoneX: Int + get() = coordInBuildArea.zoneX + public val xInZone: Int + get() = coordInBuildArea.xInZone + public val zoneZ: Int + get() = coordInBuildArea.zoneZ + public val zInZone: Int + get() = coordInBuildArea.zInZone + public val shape: Int + get() = locProperties.shape + public val rotation: Int + get() = locProperties.rotation + + public val coordInBuildAreaPacked: Int + get() = coordInBuildArea.packedMedium + public val locPropertiesPacked: Int + get() = locProperties.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocAnimSpecific + + if (_id != other._id) return false + if (coordInBuildArea != other.coordInBuildArea) return false + if (locProperties != other.locProperties) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + coordInBuildArea.hashCode() + result = 31 * result + locProperties.hashCode() + return result + } + + override fun toString(): String = + "LocAnimSpecific(" + + "id=$id, " + + "zoneX=$zoneX, " + + "xInZone=$xInZone, " + + "zoneZ=$zoneZ, " + + "zInZone=$zInZone, " + + "shape=$shape, " + + "rotation=$rotation" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/MapAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/MapAnimSpecific.kt new file mode 100644 index 000000000..7aadf54e3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/MapAnimSpecific.kt @@ -0,0 +1,125 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInBuildArea +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Map anim specific is sent to play a graphical effect/spotanim on a tile, + * local to a single user, and not the entire world. + * @property id the id of the spotanim + * @property delay the delay in client cycles (20ms/cc) until the spotanim begins playing + * @property height the height at which the spotanim will play + * @property zoneX the x coordinate of the zone's south-western corner in the + * build area. + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zoneZ the z coordinate of the zone's south-western corner in the + * build area. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * + * It should be noted that the [zoneX] and [zoneZ] coordinates are relative + * to the build area in their absolute form, not in their shifted zone form. + * If the player is at an absolute coordinate of 50, 40 within the build area(104x104), + * the expected coordinates to transmit here would be 48, 40, as that would + * point to the south-western corner of the zone in which the player is standing in. + * The client will add up the respective [zoneX] + [xInZone] properties together, + * along with [zoneZ] + [zInZone] to re-create the effects of a normal zone packet. + */ +public class MapAnimSpecific private constructor( + private val _id: UShort, + private val _delay: UShort, + private val _height: UByte, + private val coordInBuildArea: CoordInBuildArea, +) : OutgoingGameMessage { + public constructor( + id: Int, + delay: Int, + height: Int, + zoneX: Int, + xInZone: Int, + zoneZ: Int, + zInZone: Int, + ) : this( + id.toUShort(), + delay.toUShort(), + height.toUByte(), + CoordInBuildArea( + zoneX, + xInZone, + zoneZ, + zInZone, + ), + ) + + public constructor( + id: Int, + delay: Int, + height: Int, + xInBuildArea: Int, + zInBuildArea: Int, + ) : this( + id.toUShort(), + delay.toUShort(), + height.toUByte(), + CoordInBuildArea( + xInBuildArea, + zInBuildArea, + ), + ) + + public val id: Int + get() = _id.toInt() + public val delay: Int + get() = _delay.toInt() + public val height: Int + get() = _height.toInt() + public val zoneX: Int + get() = coordInBuildArea.zoneX + public val xInZone: Int + get() = coordInBuildArea.xInZone + public val zoneZ: Int + get() = coordInBuildArea.zoneZ + public val zInZone: Int + get() = coordInBuildArea.zInZone + + public val coordInBuildAreaPacked: Int + get() = coordInBuildArea.packedMedium + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MapAnimSpecific + + if (_id != other._id) return false + if (_delay != other._delay) return false + if (_height != other._height) return false + if (coordInBuildArea != other.coordInBuildArea) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _delay.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + coordInBuildArea.hashCode() + return result + } + + override fun toString(): String = + "MapAnimSpecific(" + + "id=$id, " + + "delay=$delay, " + + "height=$height, " + + "zoneX=$zoneX, " + + "xInZone=$xInZone, " + + "zoneZ=$zoneZ, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcAnimSpecific.kt new file mode 100644 index 000000000..2fb8094b5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcAnimSpecific.kt @@ -0,0 +1,64 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Npc anim specifics are used to play an animation on a NPC for a specific player, + * and not the entire world. + * @property index the index of the npc in the world + * @property id the id of the animation + * @property delay the delay of the animation before it begins playing in client cycles (20ms/cc) + */ +public class NpcAnimSpecific private constructor( + private val _index: UShort, + private val _id: UShort, + private val _delay: UByte, +) : OutgoingGameMessage { + public constructor( + index: Int, + id: Int, + delay: Int, + ) : this( + index.toUShort(), + id.toUShort(), + delay.toUByte(), + ) + + public val index: Int + get() = _index.toInt() + public val id: Int + get() = _id.toInt() + public val delay: Int + get() = _delay.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcAnimSpecific + + if (_index != other._index) return false + if (_id != other._id) return false + if (_delay != other._delay) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _id.hashCode() + result = 31 * result + _delay.hashCode() + return result + } + + override fun toString(): String = + "NpcAnimSpecific(" + + "index=$index, " + + "id=$id, " + + "delay=$delay" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcHeadIconSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcHeadIconSpecific.kt new file mode 100644 index 000000000..37dd9ec9b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcHeadIconSpecific.kt @@ -0,0 +1,90 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Npc head-icon specific packets are used to render a head icon over + * a given NPC to one user alone, and not the rest of the world. + * It is worth noting, however, that the head icon will only be set + * if the given NPC was already registered by the NPC INFO packet. + * If a given NPC is removed from the local view through NPC INFO, + * the head icon goes alongside, and will not be automatically + * restored should that NPC re-enter the local view. + * @property index the index of the npc in the world + * @property headIconSlot the slot of the head icon, a value of 0 to 7 (inclusive) + * @property spriteGroup the cache group id of the sprite. + * While the client reads a 32-bit integer for this value, the client + * does not allow for a value greater than 65535 to be used due to cache limitations, + * thus, in order to compress the packet even further, we also limit the id to a maximum + * of 65535. + * @property spriteIndex the index of the sprite within the sprite file in the cache. + * Note that this is not the id of the file in the cache group, as for sprites, this is always + * zero. Each sprite file itself defines a number of sprites - this is the index in that list + * of sprites. + * @throws IllegalArgumentException if the [headIconSlot] is not in range of 0 to 7 (inclusive) + */ +public class NpcHeadIconSpecific private constructor( + private val _index: UShort, + private val _headIconSlot: UByte, + private val _spriteGroup: UShort, + private val _spriteIndex: UShort, +) : OutgoingGameMessage { + public constructor( + index: Int, + headIconSlot: Int, + spriteGroup: Int, + spriteIndex: Int, + ) : this( + index.toUShort(), + headIconSlot.toUByte(), + spriteGroup.toUShort(), + spriteIndex.toUShort(), + ) { + require(headIconSlot in 0..<8) { + "Head icon slot must be in range of 0 to 7 (inclusive)" + } + } + + public val index: Int + get() = _index.toInt() + public val headIconSlot: Int + get() = _headIconSlot.toInt() + public val spriteGroup: Int + get() = _spriteGroup.toInt() + public val spriteIndex: Int + get() = _spriteIndex.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcHeadIconSpecific + + if (_index != other._index) return false + if (_headIconSlot != other._headIconSlot) return false + if (_spriteGroup != other._spriteGroup) return false + if (_spriteIndex != other._spriteIndex) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _headIconSlot.hashCode() + result = 31 * result + _spriteGroup.hashCode() + result = 31 * result + _spriteIndex.hashCode() + return result + } + + override fun toString(): String = + "NpcHeadIconSpecific(" + + "index=$index, " + + "headIconSlot=$headIconSlot, " + + "spriteGroup=$spriteGroup, " + + "spriteIndex=$spriteIndex" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcSpotAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcSpotAnimSpecific.kt new file mode 100644 index 000000000..d322c9ff8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/NpcSpotAnimSpecific.kt @@ -0,0 +1,83 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Npc spot-anim specific packets are used to play a spotanim on a NPC + * for a specific player and not the entire world. + * @property index the index of the NPC in the world + * @property id the id of the spotanim to play + * @property slot the slot of the spotanim + * @property height the height of the spotanim + * @property delay the delay of the spotanim in client cycles (20ms/cc) + */ +@Suppress("DuplicatedCode") +public class NpcSpotAnimSpecific private constructor( + private val _index: UShort, + private val _id: UShort, + private val _slot: UByte, + private val _height: UShort, + private val _delay: UShort, +) : OutgoingGameMessage { + public constructor( + index: Int, + id: Int, + slot: Int, + height: Int, + delay: Int, + ) : this( + index.toUShort(), + id.toUShort(), + slot.toUByte(), + height.toUShort(), + delay.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val id: Int + get() = _id.toInt() + public val slot: Int + get() = _slot.toInt() + public val height: Int + get() = _height.toInt() + public val delay: Int + get() = _delay.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NpcSpotAnimSpecific + + if (_index != other._index) return false + if (_id != other._id) return false + if (_slot != other._slot) return false + if (_height != other._height) return false + if (_delay != other._delay) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _id.hashCode() + result = 31 * result + _slot.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _delay.hashCode() + return result + } + + override fun toString(): String = + "NpcSpotAnimSpecific(" + + "index=$index, " + + "id=$id, " + + "slot=$slot, " + + "height=$height, " + + "delay=$delay" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjAddSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjAddSpecific.kt new file mode 100644 index 000000000..877ca37e4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjAddSpecific.kt @@ -0,0 +1,141 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Obj add packets are used to spawn an obj on the ground. + * + * Ownership table: + * ``` + * | Id | Ownership Type | + * |----|:--------------:| + * | 0 | None | + * | 1 | Self Player | + * | 2 | Other Player | + * | 3 | Group Ironman | + * ``` + * + * @property id the id of the obj config + * @property quantity the quantity of the obj to be spawned + * @property coordGrid the absolute coordinate at which the obj is added. + * @property opFlags the right-click options enabled on this obj. + * Use the [net.rsprot.protocol.game.outgoing.util.OpFlags] helper object to create these + * bitpacked values which can be passed into it. + * @property timeUntilPublic how many game cycles until the obj turns public. + * This property is only used on the C++-based clients. + * @property timeUntilDespawn how many game cycles until the obj disappears. + * This property is only used on the C++-based clients. + * @property ownershipType the type of ownership of this obj (see table above). + * This property is only used on the C++-based clients. + * @property neverBecomesPublic whether the item turns public in the future. + * This property is only used on the c++-based clients. + */ +@Suppress("DuplicatedCode") +public class ObjAddSpecific private constructor( + private val _id: UShort, + public val quantity: Int, + public val coordGrid: CoordGrid, + public val opFlags: Byte, + private val _timeUntilPublic: UShort, + private val _timeUntilDespawn: UShort, + private val _ownershipType: UByte, + public val neverBecomesPublic: Boolean, +) : OutgoingGameMessage { + public constructor( + id: Int, + quantity: Int, + coordGrid: CoordGrid, + opFlags: Byte, + timeUntilPublic: Int, + timeUntilDespawn: Int, + ownershipType: Int, + neverBecomesPublic: Boolean, + ) : this( + id.toUShort(), + quantity, + coordGrid, + opFlags, + timeUntilPublic.toUShort(), + timeUntilDespawn.toUShort(), + ownershipType.toUByte(), + neverBecomesPublic, + ) + + public constructor( + id: Int, + quantity: Int, + level: Int, + x: Int, + z: Int, + opFlags: Byte, + timeUntilPublic: Int, + timeUntilDespawn: Int, + ownershipType: Int, + neverBecomesPublic: Boolean, + ) : this( + id.toUShort(), + quantity, + CoordGrid(level, x, z), + opFlags, + timeUntilPublic.toUShort(), + timeUntilDespawn.toUShort(), + ownershipType.toUByte(), + neverBecomesPublic, + ) + + public val id: Int + get() = _id.toInt() + public val timeUntilPublic: Int + get() = _timeUntilPublic.toInt() + public val timeUntilDespawn: Int + get() = _timeUntilDespawn.toInt() + public val ownershipType: Int + get() = _ownershipType.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjAddSpecific + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordGrid != other.coordGrid) return false + if (opFlags != other.opFlags) return false + if (_timeUntilPublic != other._timeUntilPublic) return false + if (_timeUntilDespawn != other._timeUntilDespawn) return false + if (_ownershipType != other._ownershipType) return false + if (neverBecomesPublic != other.neverBecomesPublic) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordGrid.hashCode() + result = 31 * result + opFlags.hashCode() + result = 31 * result + _timeUntilPublic.hashCode() + result = 31 * result + _timeUntilDespawn.hashCode() + result = 31 * result + _ownershipType.hashCode() + result = 31 * result + neverBecomesPublic.hashCode() + return result + } + + override fun toString(): String = + "ObjAddSpecific(" + + "id=$id, " + + "quantity=$quantity, " + + "coordGrid=$coordGrid, " + + "opFlags=$opFlags, " + + "timeUntilPublic=$timeUntilPublic, " + + "timeUntilDespawn=$timeUntilDespawn, " + + "ownershipType=$ownershipType" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCountSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCountSpecific.kt new file mode 100644 index 000000000..f90a07a4a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCountSpecific.kt @@ -0,0 +1,85 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Obj count is a packet used to update the quantity of an obj that's already + * spawned into the build area. This is only done for objs which are private + * to a specific user - doing so merges the stacks together into one rather + * than having two distinct stacks of the same item. + * @property id the id of the obj to merge + * @property oldQuantity the old quantity of the obj to find, if no obj + * by this quantity is found, this packet has no effect client-side + * @property newQuantity the new quantity to be set to this obj + * @property coordGrid the absolute coordinate at which the obj is modified. + */ +public class ObjCountSpecific private constructor( + private val _id: UShort, + public val oldQuantity: Int, + public val newQuantity: Int, + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + oldQuantity: Int, + newQuantity: Int, + coordGrid: CoordGrid, + ) : this( + id.toUShort(), + oldQuantity, + newQuantity, + coordGrid, + ) + + public constructor( + id: Int, + oldQuantity: Int, + newQuantity: Int, + level: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + oldQuantity, + newQuantity, + CoordGrid(level, x, z), + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjCountSpecific + + if (_id != other._id) return false + if (oldQuantity != other.oldQuantity) return false + if (newQuantity != other.newQuantity) return false + if (coordGrid != other.coordGrid) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + oldQuantity + result = 31 * result + newQuantity + result = 31 * result + coordGrid.hashCode() + return result + } + + override fun toString(): String = + "ObjCountSpecific(" + + "id=$id, " + + "oldQuantity=$oldQuantity, " + + "newQuantity=$newQuantity, " + + "coordGrid=$coordGrid" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCustomiseSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCustomiseSpecific.kt new file mode 100644 index 000000000..bba4bd4a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjCustomiseSpecific.kt @@ -0,0 +1,128 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.toIntOrMinusOne + +/** + * Obj customise is a packet that allows the server to modify an item on the ground, by either changing + * the model, the colours and the textures of it. + * @property id the id of the obj to update + * @property quantity the quantity of the obj to update + * @property model the model id to assign to this obj + * @property recolIndex the index of the colour to override + * @property recol the colour value to assign at the [recolIndex] index + * @property retexIndex the index of the texture to override + * @property retex the texture value to assign at the [retexIndex] index + * @property coordGrid the absolute coordinate at which the obj is modified. + */ +public class ObjCustomiseSpecific private constructor( + private val _id: UShort, + public val quantity: Int, + private val _model: UShort, + private val _recolIndex: Short, + private val _recol: Short, + private val _retexIndex: Short, + private val _retex: Short, + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + quantity: Int, + model: Int, + recolIndex: Int, + recol: Int, + retexIndex: Int, + retex: Int, + coordGrid: CoordGrid, + ) : this( + id.toUShort(), + quantity, + model.toUShort(), + recolIndex.toShort(), + recol.toShort(), + retexIndex.toShort(), + retex.toShort(), + coordGrid, + ) + + public constructor( + id: Int, + quantity: Int, + model: Int, + recolIndex: Int, + recol: Int, + retexIndex: Int, + retex: Int, + level: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + quantity, + model.toUShort(), + recolIndex.toShort(), + recol.toShort(), + retexIndex.toShort(), + retex.toShort(), + CoordGrid(level, x, z), + ) + + public val id: Int + get() = _id.toInt() + public val model: Int + get() = _model.toIntOrMinusOne() + public val recolIndex: Int + get() = _recolIndex.toInt() + public val recol: Int + get() = _recol.toInt() + public val retexIndex: Int + get() = _retexIndex.toInt() + public val retex: Int + get() = _retex.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ObjCustomiseSpecific) return false + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (_model != other._model) return false + if (_recolIndex != other._recolIndex) return false + if (_recol != other._recol) return false + if (_retexIndex != other._retexIndex) return false + if (_retex != other._retex) return false + if (coordGrid != other.coordGrid) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + _model.hashCode() + result = 31 * result + _recolIndex + result = 31 * result + _recol + result = 31 * result + _retexIndex + result = 31 * result + _retex + result = 31 * result + coordGrid.hashCode() + return result + } + + override fun toString(): String { + return "ObjCustomiseSpecific(" + + "id=$id, " + + "quantity=$quantity, " + + "model=$model, " + + "recolIndex=$recolIndex, " + + "recol=$recol, " + + "retexIndex=$retexIndex, " + + "retex=$retex, " + + "coordGrid=$coordGrid" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjDelSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjDelSpecific.kt new file mode 100644 index 000000000..336815f82 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjDelSpecific.kt @@ -0,0 +1,76 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Obj del packets are used to delete an existing obj from the build area, + * assuming it exists in the first place. + * @property id the id of the obj to delete. Note that the client does bitwise-and + * on the id to cap it to the lowest 15 bits, meaning the maximum id that can be + * transmitted is 32767. + * @property quantity the quantity of the obj to be deleted. If there is no obj + * with this quantity, nothing will be deleted. + * @property coordGrid the absolute coordinate at which the obj is deleted. + */ +public class ObjDelSpecific private constructor( + private val _id: UShort, + public val quantity: Int, + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + quantity: Int, + coordGrid: CoordGrid, + ) : this( + id.toUShort(), + quantity, + coordGrid, + ) + + public constructor( + id: Int, + quantity: Int, + level: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + quantity, + CoordGrid(level, x, z), + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjDelSpecific + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordGrid != other.coordGrid) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordGrid.hashCode() + return result + } + + override fun toString(): String = + "ObjDelSpecific(" + + "id=$id, " + + "quantity=$quantity, " + + "coordGrid=$coordGrid" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjEnabledOpsSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjEnabledOpsSpecific.kt new file mode 100644 index 000000000..d354f0676 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjEnabledOpsSpecific.kt @@ -0,0 +1,77 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Obj enabled ops is used to change the right-click options on an obj + * on the ground. This packet is currently unused in OldSchool RuneScape. + * It works by finding the first obj in the stack with the provided [id], + * and modifying the right-click ops on that. It does not verify quantity. + * @property id the id of the obj that needs to get its ops changed + * @property opFlags the right-click options to set enabled on that obj. + * Use the [net.rsprot.protocol.game.outgoing.util.OpFlags] helper object to create these + * bitpacked values which can be passed into it. + * @property coordGrid the absolute coordinate at which the obj is modified. + */ +public class ObjEnabledOpsSpecific private constructor( + private val _id: UShort, + public val opFlags: Byte, + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + opFlags: Byte, + coordGrid: CoordGrid, + ) : this( + id.toUShort(), + opFlags, + coordGrid, + ) + + public constructor( + id: Int, + opFlags: Byte, + level: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + opFlags, + CoordGrid(level, x, z), + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjEnabledOpsSpecific + + if (_id != other._id) return false + if (opFlags != other.opFlags) return false + if (coordGrid != other.coordGrid) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + opFlags.hashCode() + result = 31 * result + coordGrid.hashCode() + return result + } + + override fun toString(): String = + "ObjEnabledOpsSpecific(" + + "id=$id, " + + "opFlags=$opFlags, " + + "coordGrid=$coordGrid" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjUncustomiseSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjUncustomiseSpecific.kt new file mode 100644 index 000000000..dc683d1a8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ObjUncustomiseSpecific.kt @@ -0,0 +1,71 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Obj uncustomise resets any customisations done to an obj via the [ObjCustomiseSpecific] packet. + * @property id the id of the obj to update + * @property quantity the quantity of the obj to update + * @property coordGrid the absolute coordinate at which the obj is modified. + */ +public class ObjUncustomiseSpecific private constructor( + private val _id: UShort, + public val quantity: Int, + public val coordGrid: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + quantity: Int, + coordGrid: CoordGrid, + ) : this( + id.toUShort(), + quantity, + coordGrid, + ) + + public constructor( + id: Int, + quantity: Int, + level: Int, + x: Int, + z: Int, + ) : this( + id.toUShort(), + quantity, + CoordGrid(level, x, z), + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ObjUncustomiseSpecific) return false + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordGrid != other.coordGrid) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordGrid.hashCode() + return result + } + + override fun toString(): String { + return "ObjCustomiseSpecific(" + + "id=$id, " + + "quantity=$quantity, " + + "coordGrid=$coordGrid" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerAnimSpecific.kt new file mode 100644 index 000000000..e0cbb6ff6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerAnimSpecific.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Player anim specifics are used to play an animation on the local player for the local player, + * not the entire world. + * Note that unlike most other packets, this one does not provide the index, so it can only + * be played on the local player and no one else. + * @property id the id of the animation + * @property delay the delay of the animation before it begins playing in client cycles (20ms/cc) + */ +public class PlayerAnimSpecific private constructor( + private val _id: UShort, + private val _delay: UByte, +) : OutgoingGameMessage { + public constructor( + id: Int, + delay: Int, + ) : this( + id.toUShort(), + delay.toUByte(), + ) + + public val id: Int + get() = _id.toInt() + public val delay: Int + get() = _delay.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerAnimSpecific + + if (_id != other._id) return false + if (_delay != other._delay) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _delay.hashCode() + return result + } + + override fun toString(): String = + "PlayerAnimSpecific(" + + "id=$id, " + + "delay=$delay" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerSpotAnimSpecific.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerSpotAnimSpecific.kt new file mode 100644 index 000000000..68eb20ed1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/PlayerSpotAnimSpecific.kt @@ -0,0 +1,83 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Npc spot-anim specific packets are used to play a spotanim on a player + * for a specific player and not the entire world. + * @property index the index of the player in the world + * @property id the id of the spotanim to play + * @property slot the slot of the spotanim + * @property height the height of the spotanim + * @property delay the delay of the spotanim in client cycles (20ms/cc) + */ +@Suppress("DuplicatedCode") +public class PlayerSpotAnimSpecific private constructor( + private val _index: UShort, + private val _id: UShort, + private val _slot: UByte, + private val _height: UShort, + private val _delay: UShort, +) : OutgoingGameMessage { + public constructor( + index: Int, + id: Int, + slot: Int, + height: Int, + delay: Int, + ) : this( + index.toUShort(), + id.toUShort(), + slot.toUByte(), + height.toUShort(), + delay.toUShort(), + ) + + public val index: Int + get() = _index.toInt() + public val id: Int + get() = _id.toInt() + public val slot: Int + get() = _slot.toInt() + public val height: Int + get() = _height.toInt() + public val delay: Int + get() = _delay.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerSpotAnimSpecific + + if (_index != other._index) return false + if (_id != other._id) return false + if (_slot != other._slot) return false + if (_height != other._height) return false + if (_delay != other._delay) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _id.hashCode() + result = 31 * result + _slot.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + _delay.hashCode() + return result + } + + override fun toString(): String = + "PlayerSpotAnimSpecific(" + + "index=$index, " + + "id=$id, " + + "slot=$slot, " + + "height=$height, " + + "delay=$delay" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ProjAnimSpecificV4.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ProjAnimSpecificV4.kt new file mode 100644 index 000000000..dff0f5d29 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/specific/ProjAnimSpecificV4.kt @@ -0,0 +1,192 @@ +package net.rsprot.protocol.game.outgoing.specific + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Proj anim specific packets are used to send a projectile for a specific user, + * without anyone else in the world seeing it. + * Unlike the previous specific projectiles and other zone packets, this one writes absolute coordinates, + * not zone-local ones. + * Additionally, unlike the older packets, this one does not implicitly multiply heights by 4. + * Servers must do it themselves to end up with the same output. + + * @property id the id of the spotanim that is this projectile + * @property startHeight the height of the projectile as it begins flying. Note that unlike in the past, + * the client does not multiply this by 4 implicitly. + * @property endHeight the height of the projectile as it finishes flying. Note that unlike in the past, + * the client does not multiply this by 4 implicitly. + * @property startTime the start time in client cycles (20ms/cc) until the + * projectile begins moving + * @property endTime the end time in client cycles (20ms/cc) until the + * projectile arrives at its destination + * @property angle the angle that the projectile takes during its flight + * @property progress the fine coord progress that the projectile + * has made before it begins flying. If the value is 0, the projectile begins flying + * at the defined start coordinate. For every 128 units of value, the projectile + * is moved 1 game square towards the end position. Interpolate between 0-128 for + * units smaller than 1 game square. + * This is commonly set to 128 to make a projectile appear as if it's flying + * straight down, as the projectile will not render if its defined start and + * end coords are equal. So, in order to avoid that, one solution is to put the + * end coordinate 1 game square away from the start in a cardinal direction, + * and set the value of this property to 128 - ensuring that the projectile + * will appear to fly completely vertically, with no horizontal movement whatsoever. + * In the event inspector, this property is called 'distanceOffset'. + * @property sourceIndex the index of the pathing entity from whom the projectile comes. + * If the value is 0, the projectile will not be locked to any source entity. + * + * If the source avatar is a player, set the value as `-(index + 1)` + * + * If the source avatar is a NPC, set the value as `(index + 1)` + * @property targetIndex the index of the pathing entity at whom the projectile is shot. + * If the value is 0, the projectile will not be locked to any target entity. + * + * If the target avatar is a player, set the value as `-(index + 1)` + * + * If the target avatar is a NPC, set the value as `(index + 1)` + * @property start the starting coordinate from which the projectile begins flying + * @property end the ending coordinate to which the projectile will fly when not locked onto + * a target. + */ +@Suppress("DuplicatedCode") +public class ProjAnimSpecificV4 private constructor( + private val _id: UShort, + private val _startHeight: UShort, + private val _endHeight: UShort, + private val _startTime: UShort, + private val _endTime: UShort, + private val _angle: UByte, + private val _progress: UShort, + public val sourceIndex: Int, + public val targetIndex: Int, + public val start: CoordGrid, + public val end: CoordGrid, +) : OutgoingGameMessage { + public constructor( + id: Int, + startHeight: Int, + endHeight: Int, + startTime: Int, + endTime: Int, + angle: Int, + progress: Int, + start: CoordGrid, + sourceIndex: Int, + end: CoordGrid, + targetIndex: Int, + ) : this( + id.toUShort(), + startHeight.toUShort(), + endHeight.toUShort(), + startTime.toUShort(), + endTime.toUShort(), + angle.toUByte(), + progress.toUShort(), + sourceIndex, + targetIndex, + start, + end, + ) + + public constructor( + id: Int, + startHeight: Int, + endHeight: Int, + startTime: Int, + endTime: Int, + angle: Int, + progress: Int, + startX: Int, + startZ: Int, + startLevel: Int, + sourceIndex: Int, + endX: Int, + endZ: Int, + endLevel: Int, + targetIndex: Int, + ) : this( + id.toUShort(), + startHeight.toUShort(), + endHeight.toUShort(), + startTime.toUShort(), + endTime.toUShort(), + angle.toUByte(), + progress.toUShort(), + sourceIndex, + targetIndex, + CoordGrid(startLevel, startX, startZ), + CoordGrid(endLevel, endX, endZ), + ) + + public val id: Int + get() = _id.toInt() + public val startHeight: Int + get() = _startHeight.toInt() + public val endHeight: Int + get() = _endHeight.toInt() + public val startTime: Int + get() = _startTime.toInt() + public val endTime: Int + get() = _endTime.toInt() + public val angle: Int + get() = _angle.toInt() + public val progress: Int + get() = _progress.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.LOW_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ProjAnimSpecificV4 + + if (_id != other._id) return false + if (_startHeight != other._startHeight) return false + if (_endHeight != other._endHeight) return false + if (_startTime != other._startTime) return false + if (_endTime != other._endTime) return false + if (_angle != other._angle) return false + if (_progress != other._progress) return false + if (sourceIndex != other.sourceIndex) return false + if (targetIndex != other.targetIndex) return false + if (start != other.start) return false + if (end != other.end) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _startHeight.hashCode() + result = 31 * result + _endHeight.hashCode() + result = 31 * result + _startTime.hashCode() + result = 31 * result + _endTime.hashCode() + result = 31 * result + _angle.hashCode() + result = 31 * result + _progress.hashCode() + result = 31 * result + sourceIndex + result = 31 * result + targetIndex + result = 31 * result + start.hashCode() + result = 31 * result + end.hashCode() + return result + } + + override fun toString(): String = + "ProjAnimSpecificV4(" + + "id=$id, " + + "startHeight=$startHeight, " + + "endHeight=$endHeight, " + + "startTime=$startTime, " + + "endTime=$endTime, " + + "angle=$angle, " + + "progress=$progress, " + + "start=$start, " + + "sourceIndex=$sourceIndex, " + + "end=$end, " + + "targetIndex=$targetIndex, " + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/util/OpFlags.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/util/OpFlags.kt new file mode 100644 index 000000000..82ae8bb36 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/util/OpFlags.kt @@ -0,0 +1,52 @@ +package net.rsprot.protocol.game.outgoing.util + +/** + * Op flags are used to hide or show certain right-click options on various + * interactable entities. + * This is a helper class to create various combinations of this flag. + */ +@Suppress("MemberVisibilityCanBePrivate") +public object OpFlags { + /** + * A constant flag for 'show all options' on an entity. + */ + public const val ALL_SHOWN: Byte = -1 + + /** + * A constant flag for 'show no options' on an entity. + */ + public const val NONE_SHOWN: Byte = 0 + + @JvmSynthetic + public operator fun invoke( + op1: Boolean, + op2: Boolean, + op3: Boolean, + op4: Boolean, + op5: Boolean, + ): Byte = ofOps(op1, op2, op3, op4, op5) + + /** + * Returns the bitpacked op flag out of the provided booleans. + */ + @JvmStatic + public fun ofOps( + op1: Boolean, + op2: Boolean, + op3: Boolean, + op4: Boolean, + op5: Boolean, + ): Byte = + toInt(op1) + .or(toInt(op2) shl 1) + .or(toInt(op3) shl 2) + .or(toInt(op4) shl 3) + .or(toInt(op5) shl 4) + .toByte() + + /** + * Turns the boolean to an integer. + * @return 1 if the boolean is enabled, 0 otherwise. + */ + private fun toInt(value: Boolean): Int = if (value) 1 else 0 +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpLarge.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpLarge.kt new file mode 100644 index 000000000..4552ef2be --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpLarge.kt @@ -0,0 +1,56 @@ +package net.rsprot.protocol.game.outgoing.varp + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Varp large messages are used to send a varp to the client that + * has a value which does not fit in the range of a byte, being -128..127. + * For values which do fit in the aforementioned range, the [VarpSmall] + * message is preferred as it takes up less bandwidth, although nothing + * prevents one from sending all varps using this variant. + * @property id the id of the varp + * @property value the value of the varp + */ +public class VarpLarge private constructor( + private val _id: UShort, + public val value: Int, +) : OutgoingGameMessage { + public constructor( + id: Int, + value: Int, + ) : this( + id.toUShort(), + value, + ) + + public val id: Int + get() = _id.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarpLarge + + if (_id != other._id) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + value + return result + } + + override fun toString(): String = + "VarpLarge(" + + "id=$id, " + + "value=$value" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpReset.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpReset.kt new file mode 100644 index 000000000..df573e40a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpReset.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.game.outgoing.varp + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * The varp reset packet is used to set the values of every + * varplayer type to 0. + * It is worth noting that the client will only reset the varps + * up until the last one which has a respective cache config. + * So if the varps array is extended, but respective configs + * are not made, the extended ones will not be zero'd out. + */ +public data object VarpReset : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSmall.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSmall.kt new file mode 100644 index 000000000..ba75ba8f1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSmall.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.game.outgoing.varp + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Varp small messages are used to send a varp to the client that + * has a value which fits in the range of a byte, being -128..127. + * Note that this class does not verify that the value is in the correct + * range - instead any bits beyond the range of a byte get ignored. + * @property id the id of the varp + * @property value the value of the varp, in range of -128 to 127 (inclusive) + */ +public class VarpSmall private constructor( + private val _id: UShort, + private val _value: Byte, +) : OutgoingGameMessage { + public constructor( + id: Int, + value: Int, + ) : this( + id.toUShort(), + value.toByte(), + ) + + public val id: Int + get() = _id.toInt() + public val value: Int + get() = _value.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VarpSmall + + if (_id != other._id) return false + if (_value != other._value) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _value + return result + } + + override fun toString(): String = + "VarpSmall(" + + "id=$id, " + + "value=$value" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSync.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSync.kt new file mode 100644 index 000000000..9b4235dad --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/varp/VarpSync.kt @@ -0,0 +1,19 @@ +package net.rsprot.protocol.game.outgoing.varp + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * The varp sync packet is used to synchronize the client's cache + * of varps back up with the server's version. + * + * The client keeps two int arrays for varps one that it modifies, + * and one that is a perfect replica of what the server has sent. + * This packet provides a means to sync the modified variant up + * with what the server has sent. + */ +public data object VarpSync : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/SetActiveWorldV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/SetActiveWorldV2.kt new file mode 100644 index 000000000..8b902282f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/SetActiveWorldV2.kt @@ -0,0 +1,143 @@ +package net.rsprot.protocol.game.outgoing.worldentity + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Set active world packet is used to set the currently active world in the client, + * allowing for various world-specific packets to perform changes to a different world + * than the usual root. + * Packets such as zone updates, player info, NPC info are a few examples of what may be sent afterwards. + * @property worldType the world type to update next. + */ +public class SetActiveWorldV2( + public val worldType: WorldType, +) : OutgoingGameMessage { + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SetActiveWorldV2 + + return worldType == other.worldType + } + + override fun hashCode(): Int = worldType.hashCode() + + override fun toString(): String = "SetActiveWorldV2(worldType=$worldType)" + + /** + * A world type to set as the currently active world, allowing for updates + * to be done to that specific world. + */ + public sealed interface WorldType + + /** + * The root world type, resetting currently world to the main one. + * @property activeLevel the level at which various events will take place, such as + * zone updates. + */ + public class RootWorldType private constructor( + private val _activeLevel: UByte, + ) : WorldType { + public constructor(activeLevel: Int) : this(activeLevel.toUByte()) { + require(activeLevel in 0..<4) { + "Active level must be in range of 0..<4" + } + } + + public val activeLevel: Int + get() = _activeLevel.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RootWorldType + + return _activeLevel == other._activeLevel + } + + override fun hashCode(): Int = _activeLevel.hashCode() + + override fun toString(): String = "RootWorldType(activeLevel=$activeLevel)" + } + + /** + * A dynamic world type is used to mark one of the world entities' worlds as + * the active world, allowing for changes to be sent to that world entity. + * @property index the index of the world entity whose world is about to be updated, + * in range of 0..<2048. + * @property activeLevel the level at which various events will take place, such as + * zone updates. + */ + public class DynamicWorldType private constructor( + private val _index: UShort, + private val _activeLevel: UByte, + ) : WorldType { + public constructor( + index: Int, + activeLevel: Int, + ) : this( + index.toUShort(), + activeLevel.toUByte(), + ) { + require(index in 0..<2048) { + "Index must be in range of 0..<2048" + } + require(activeLevel in 0..<4) { + "Active level must be in range of 0..<4" + } + } + + public val index: Int + get() = _index.toInt() + public val activeLevel: Int + get() = _activeLevel.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DynamicWorldType + + if (_index != other._index) return false + if (_activeLevel != other._activeLevel) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _activeLevel.hashCode() + return result + } + + override fun toString(): String = + "DynamicWorldType(" + + "index=$index, " + + "activeLevel=$activeLevel" + + ")" + } + + public companion object { + public val ROOT_ZERO: SetActiveWorldV2 = SetActiveWorldV2(RootWorldType(0)) + public val ROOT_ONE: SetActiveWorldV2 = SetActiveWorldV2(RootWorldType(1)) + public val ROOT_TWO: SetActiveWorldV2 = SetActiveWorldV2(RootWorldType(2)) + public val ROOT_THREE: SetActiveWorldV2 = SetActiveWorldV2(RootWorldType(3)) + + public fun getRoot(level: Int): SetActiveWorldV2 { + return when (level) { + 0 -> ROOT_ZERO + 1 -> ROOT_ONE + 2 -> ROOT_TWO + 3 -> ROOT_THREE + else -> error("Invalid level: $level") + } + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/WorldEntityTypealiases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/WorldEntityTypealiases.kt new file mode 100644 index 000000000..f10040720 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/worldentity/WorldEntityTypealiases.kt @@ -0,0 +1,9 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.worldentity + +@Deprecated( + message = "Deprecated. Use SetActiveWorldV2.", + replaceWith = ReplaceWith("SetActiveWorldV2"), +) +public typealias SetActiveWorld = SetActiveWorldV2 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZoneFullFollows.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZoneFullFollows.kt new file mode 100644 index 000000000..538b1c3fe --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZoneFullFollows.kt @@ -0,0 +1,77 @@ +package net.rsprot.protocol.game.outgoing.zone.header + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update zone full-follows packets are used to clear a zone (8x8x1 tiles space) + * from any modifications done to it prior, wiping any obj and loc changes in + * the process. This packet additionally sets the 'current zone pointer' to this + * zone, allowing one to follow it with any other zone payload packet, commonly + * used to synchronize the zone to the observer (restoring all the objs in it, + * loc changes and so on). + * @property zoneX the x coordinate of the zone's south-western corner in the + * build area. + * @property zoneZ the z coordinate of the zone's south-western corner in the + * build area. + * @property level the height level of the zone, typically equal to the player's + * own height level. + * + * It should be noted that the [zoneX] and [zoneZ] coordinates are relative + * to the build area in their absolute form, not in their shifted zone form. + * If the player is at an absolute coordinate of 50, 40 within the build area(104x104), + * the expected coordinates to transmit here would be 48, 40, as that would + * point to the south-western corner of the zone in which the player is standing in. + */ +public class UpdateZoneFullFollows private constructor( + private val _zoneX: UByte, + private val _zoneZ: UByte, + private val _level: UByte, +) : OutgoingGameMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + level: Int, + ) : this( + zoneX.toUByte(), + zoneZ.toUByte(), + level.toUByte(), + ) + + public val zoneX: Int + get() = _zoneX.toInt() + public val zoneZ: Int + get() = _zoneZ.toInt() + public val level: Int + get() = _level.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateZoneFullFollows + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (_level != other._level) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + _level.hashCode() + return result + } + + override fun toString(): String = + "UpdateZoneFullFollows(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "level=$level" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialEnclosed.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialEnclosed.kt new file mode 100644 index 000000000..f65f35aab --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialEnclosed.kt @@ -0,0 +1,94 @@ +package net.rsprot.protocol.game.outgoing.zone.header + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.ByteBufHolderWrapperHeaderMessage +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update zone partial-enclosed is used to send a batch of updates for a given + * zone all in one packet. This results in less bandwidth being used, as well as + * avoiding the client limitations of 100 packets/client cycle (20ms/cc). + * @property zoneX the x coordinate of the zone's south-western corner in the + * build area. + * @property zoneZ the z coordinate of the zone's south-western corner in the + * build area. + * @property level the height level of the zone, typically equal to the player's + * own height level. + * + * It should be noted that the [zoneX] and [zoneZ] coordinates are relative + * to the build area in their absolute form, not in their shifted zone form. + * If the player is at an absolute coordinate of 50, 40 within the build area(104x104), + * the expected coordinates to transmit here would be 48, 40, as that would + * point to the south-western corner of the zone in which the player is standing in. + */ +public class UpdateZonePartialEnclosed private constructor( + private val _zoneX: UByte, + private val _zoneZ: UByte, + private val _level: UByte, + public val payload: ByteBuf, +) : DefaultByteBufHolder(payload), + OutgoingGameMessage, + ByteBufHolderWrapperHeaderMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + level: Int, + payload: ByteBuf, + ) : this( + zoneX.toUByte(), + zoneZ.toUByte(), + level.toUByte(), + payload.retain(), + ) + + public val zoneX: Int + get() = _zoneX.toInt() + public val zoneZ: Int + get() = _zoneZ.toInt() + public val level: Int + get() = _level.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun estimateSize(): Int = + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + payload.readableBytes() + + override fun nonByteBufHolderSize(): Int { + return Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + Byte.SIZE_BYTES + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateZonePartialEnclosed + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (_level != other._level) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + _level.hashCode() + return result + } + + override fun toString(): String = + "UpdateZonePartialEnclosed(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "level=$level" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialFollows.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialFollows.kt new file mode 100644 index 000000000..c6b3f4187 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/header/UpdateZonePartialFollows.kt @@ -0,0 +1,76 @@ +package net.rsprot.protocol.game.outgoing.zone.header + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.message.OutgoingGameMessage + +/** + * Update zone partial follows packets are used to set the 'current zone pointer' to this + * zone, allowing one to follow it with any other zone payload packet. + * This packet is more efficient to use over the partial-enclosed variant + * when there is only a single zone packet following it, in any other scenario, + * it is more bandwidth-friendly to use the enclosed packet. + * @property zoneX the x coordinate of the zone's south-western corner in the + * build area. + * @property zoneZ the z coordinate of the zone's south-western corner in the + * build area. + * @property level the height level of the zone, typically equal to the player's + * own height level. + * + * It should be noted that the [zoneX] and [zoneZ] coordinates are relative + * to the build area in their absolute form, not in their shifted zone form. + * If the player is at an absolute coordinate of 50, 40 within the build area(104x104), + * the expected coordinates to transmit here would be 48, 40, as that would + * point to the south-western corner of the zone in which the player is standing in. + */ +public class UpdateZonePartialFollows private constructor( + private val _zoneX: UByte, + private val _zoneZ: UByte, + private val _level: UByte, +) : OutgoingGameMessage { + public constructor( + zoneX: Int, + zoneZ: Int, + level: Int, + ) : this( + zoneX.toUByte(), + zoneZ.toUByte(), + level.toUByte(), + ) + + public val zoneX: Int + get() = _zoneX.toInt() + public val zoneZ: Int + get() = _zoneZ.toInt() + public val level: Int + get() = _level.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UpdateZonePartialFollows + + if (_zoneX != other._zoneX) return false + if (_zoneZ != other._zoneZ) return false + if (_level != other._level) return false + + return true + } + + override fun hashCode(): Int { + var result = _zoneX.hashCode() + result = 31 * result + _zoneZ.hashCode() + result = 31 * result + _level.hashCode() + return result + } + + override fun toString(): String = + "UpdateZonePartialFollows(" + + "zoneX=$zoneX, " + + "zoneZ=$zoneZ, " + + "level=$level" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAddChangeV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAddChangeV2.kt new file mode 100644 index 000000000..f55cf36bb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAddChangeV2.kt @@ -0,0 +1,127 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.game.outgoing.zone.payload.util.LocProperties +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Loc add-change v2 packet is used to either add or change a loc in the world. + * The client will add a new loc if none exists by this description, + * or overwrites an old one with the same layer (layer is obtained through the [shape] + * property of the loc). + * @property id the id of the loc to add + * @property xInZone the x coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property shape the shape of the loc, a value of 0 to 22 (inclusive) is expected. + * @property rotation the rotation of the loc, a value of 0 to 3 (inclusive) is expected. + * @property opFlags the right-click options enabled on this loc. + * Use the [net.rsprot.protocol.game.outgoing.util.OpFlags] helper object to create these + * bitpacked values which can be passed into it. + * @property ops a map of mini menu ops to override the defaults with. + * If the map is null or empty, the ops will not be overridden and the ones provided in the + * respective cache config will be used. If the map has entries, **all** the cache ops are + * ignored and the provided map is used. Note that only ops 1-5 will actually be used, any + * other values get ignored by the client. As such, if a map is provided that has no keys + * of value 1-5, all the ops will simply be hidden. + */ +public class LocAddChangeV2 private constructor( + private val _id: UShort, + private val coordInZone: CoordInZone, + private val locProperties: LocProperties, + public val opFlags: Byte, + public val ops: Map?, +) : ZoneProt { + public constructor( + id: Int, + xInZone: Int, + zInZone: Int, + shape: Int, + rotation: Int, + opFlags: Byte, + ops: Map?, + ) : this( + id.toUShort(), + CoordInZone(xInZone, zInZone), + LocProperties(shape, rotation), + opFlags, + ops, + ) + + public constructor( + id: Int, + xInZone: Int, + zInZone: Int, + shape: Int, + rotation: Int, + opFlags: Byte, + ) : this( + id.toUShort(), + CoordInZone(xInZone, zInZone), + LocProperties(shape, rotation), + opFlags, + null, + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + public val shape: Int + get() = locProperties.shape + public val rotation: Int + get() = locProperties.rotation + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + public val locPropertiesPacked: Int + get() = locProperties.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override val protId: Int = OldSchoolZoneProt.LOC_ADD_CHANGE_V2 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocAddChangeV2 + + if (_id != other._id) return false + if (coordInZone != other.coordInZone) return false + if (locProperties != other.locProperties) return false + if (opFlags != other.opFlags) return false + if (ops != other.ops) return false + if (protId != other.protId) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + coordInZone.hashCode() + result = 31 * result + locProperties.hashCode() + result = 31 * result + opFlags + result = 31 * result + (ops?.hashCode() ?: 0) + result = 31 * result + protId + return result + } + + override fun toString(): String { + return "LocAddChangeV2(" + + "id=$id, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "shape=$shape, " + + "rotation=$rotation" + + "opFlags=$opFlags, " + + "ops=$ops, " + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAnim.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAnim.kt new file mode 100644 index 000000000..c7ebe2fbe --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocAnim.kt @@ -0,0 +1,85 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.game.outgoing.zone.payload.util.LocProperties +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Loc anim packets are used to make a loc play an animation. + * @property id the id of the animation to play + * @property xInZone the x coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property shape the shape of the loc, a value of 0 to 22 (inclusive) is expected. + * @property rotation the rotation of the loc, a value of 0 to 3 (inclusive) is expected. + */ +public class LocAnim private constructor( + private val _id: UShort, + private val coordInZone: CoordInZone, + private val locProperties: LocProperties, +) : ZoneProt { + public constructor( + id: Int, + xInZone: Int, + zInZone: Int, + shape: Int, + rotation: Int, + ) : this( + id.toUShort(), + CoordInZone(xInZone, zInZone), + LocProperties(shape, rotation), + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + public val shape: Int + get() = locProperties.shape + public val rotation: Int + get() = locProperties.rotation + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + public val locPropertiesPacked: Int + get() = locProperties.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override val protId: Int = OldSchoolZoneProt.LOC_ANIM + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocAnim + + if (_id != other._id) return false + if (coordInZone != other.coordInZone) return false + if (locProperties != other.locProperties) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + coordInZone.hashCode() + result = 31 * result + locProperties.hashCode() + return result + } + + override fun toString(): String = + "LocAnim(" + + "id=$id, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "shape=$shape, " + + "rotation=$rotation" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocDel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocDel.kt new file mode 100644 index 000000000..d2280288e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocDel.kt @@ -0,0 +1,76 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.game.outgoing.zone.payload.util.LocProperties +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Loc del packets are used to delete locs from the world. + * @property xInZone the x coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property shape the shape of the loc, a value of 0 to 22 (inclusive) is expected. + * @property rotation the rotation of the loc, a value of 0 to 3 (inclusive) is expected. + */ +public class LocDel private constructor( + private val coordInZone: CoordInZone, + private val locProperties: LocProperties, +) : ZoneProt { + public constructor( + xInZone: Int, + zInZone: Int, + shape: Int, + rotation: Int, + ) : this( + CoordInZone(xInZone, zInZone), + LocProperties(shape, rotation), + ) + + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + public val shape: Int + get() = locProperties.shape + public val rotation: Int + get() = locProperties.rotation + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + public val locPropertiesPacked: Int + get() = locProperties.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + + override val protId: Int = OldSchoolZoneProt.LOC_DEL + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocDel + + if (coordInZone != other.coordInZone) return false + if (locProperties != other.locProperties) return false + + return true + } + + override fun hashCode(): Int { + var result = coordInZone.hashCode() + result = 31 * result + locProperties.hashCode() + return result + } + + override fun toString(): String = + "LocDel(" + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "shape=$shape, " + + "rotation=$rotation" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocMerge.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocMerge.kt new file mode 100644 index 000000000..0e1404be2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/LocMerge.kt @@ -0,0 +1,159 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.game.outgoing.zone.payload.util.LocProperties +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Loc merge packets are used to merge a given loc's model with the player's + * own model, preventing any visual clipping problems in the process. + * This is commonly done with obstacle pipes in agility courses, as + * the player model will otherwise render through the pipes. + * + * The merge will cover a rectangle defined by the [minX], [minZ], [maxX] and [maxZ] + * properties, relative to the player who is being merged. It should be noted + * that the client adds an extra 1 to the total width/height values here, + * so having all these properties at zero would still create a single + * tile square to be merged. + * + * @property index the index of the player who is being merged + * @property id the id of the loc that is being merged with the player + * @property xInZone the x coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the loc within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property shape the shape of the loc, a value of 0 to 22 (inclusive) is expected. + * @property rotation the rotation of the loc, a value of 0 to 3 (inclusive) is expected. + * @property start the delay until the loc merging begins, in client cycles (20ms/cc). + * @property end the client cycle (20ms/cc) at which the merging ends. + * @property minX the min x coordinate at which the merge occurs (see explanation above) + * @property minZ the min z coordinate at which the merge occurs (see explanation above) + * @property maxX the max x coordinate at which the merge occurs (see explanation above) + * @property maxZ the max z coordinate at which the merge occurs (see explanation above) + */ +@Suppress("DuplicatedCode") +public class LocMerge private constructor( + private val _index: UShort, + private val _id: UShort, + private val coordInZone: CoordInZone, + private val locProperties: LocProperties, + private val _start: UShort, + private val _end: UShort, + private val _minX: Byte, + private val _minZ: Byte, + private val _maxX: Byte, + private val _maxZ: Byte, +) : ZoneProt { + public constructor( + index: Int, + id: Int, + xInZone: Int, + zInZone: Int, + shape: Int, + rotation: Int, + start: Int, + end: Int, + minX: Int, + minZ: Int, + maxX: Int, + maxZ: Int, + ) : this( + index.toUShort(), + id.toUShort(), + CoordInZone(xInZone, zInZone), + LocProperties(shape, rotation), + start.toUShort(), + end.toUShort(), + minX.toByte(), + minZ.toByte(), + maxX.toByte(), + maxZ.toByte(), + ) + + public val index: Int + get() = _index.toInt() + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + public val shape: Int + get() = locProperties.shape + public val rotation: Int + get() = locProperties.rotation + public val start: Int + get() = _start.toInt() + public val end: Int + get() = _end.toInt() + public val minX: Int + get() = _minX.toInt() + public val minZ: Int + get() = _minZ.toInt() + public val maxX: Int + get() = _maxX.toInt() + public val maxZ: Int + get() = _maxZ.toInt() + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + public val locPropertiesPacked: Int + get() = locProperties.packed.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.LOC_MERGE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocMerge + + if (_index != other._index) return false + if (_id != other._id) return false + if (coordInZone != other.coordInZone) return false + if (locProperties != other.locProperties) return false + if (_start != other._start) return false + if (_end != other._end) return false + if (_minX != other._minX) return false + if (_minZ != other._minZ) return false + if (_maxX != other._maxX) return false + if (_maxZ != other._maxZ) return false + + return true + } + + override fun hashCode(): Int { + var result = _index.hashCode() + result = 31 * result + _id.hashCode() + result = 31 * result + coordInZone.hashCode() + result = 31 * result + locProperties.hashCode() + result = 31 * result + _start.hashCode() + result = 31 * result + _end.hashCode() + result = 31 * result + _minX.hashCode() + result = 31 * result + _minZ.hashCode() + result = 31 * result + _maxX.hashCode() + result = 31 * result + _maxZ.hashCode() + return result + } + + override fun toString(): String = + "LocMerge(" + + "index=$index, " + + "id=$id, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "shape=$shape, " + + "rotation=$rotation, " + + "start=$start, " + + "end=$end, " + + "minX=$minX, " + + "minZ=$minZ, " + + "maxX=$maxX, " + + "maxZ=$maxZ" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapAnim.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapAnim.kt new file mode 100644 index 000000000..ed80977f4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapAnim.kt @@ -0,0 +1,86 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Map anim is sent to play a graphical effect/spotanim on a tile. + * @property id the id of the spotanim + * @property delay the delay in client cycles (20ms/cc) until the spotanim begins playing + * @property height the height at which the spotanim will play + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class MapAnim private constructor( + private val _id: UShort, + private val _delay: UShort, + private val _height: UByte, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + delay: Int, + height: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + delay.toUShort(), + height.toUByte(), + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val delay: Int + get() = _delay.toInt() + public val height: Int + get() = _height.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.MAP_ANIM + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MapAnim + + if (_id != other._id) return false + if (_delay != other._delay) return false + if (_height != other._height) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _delay.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String = + "MapAnim(" + + "id=$id, " + + "delay=$delay, " + + "height=$height, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapProjAnimV2.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapProjAnimV2.kt new file mode 100644 index 000000000..43e470ac3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/MapProjAnimV2.kt @@ -0,0 +1,204 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.internal.game.outgoing.info.CoordGrid +import net.rsprot.protocol.message.ZoneProt + +/** + * Map projectile anim packets are sent to render projectiles + * from one coord to another. + * + * This packet writes an absolute coordinate for the end coordinate, unlike in the past where it was relative + * to the starting coordinate. + * Additionally, the [startHeight] and [endHeight] variables no longer come with an implicit * 4 multiplier + * in the client. + * + * @property id the id of the spotanim that is this projectile + * @property startHeight the height of the projectile as it begins flying. Note that this is + * not implicitly multiplied by 4 as it was in the past. + * @property endHeight the height of the projectile as it finishes flying. Note that this is + * not implicitly multiplied by 4 as it was in the past. + * @property startTime the start time in client cycles (20ms/cc) until the + * projectile begins moving + * @property endTime the end time in client cycles (20ms/cc) until the + * projectile arrives at its destination + * @property angle the angle that the projectile takes during its flight + * @property progress the fine coord distance offset that the projectile + * begins flying at. If the value is 0, the projectile begins flying + * at the defined start coordinate. For every 128 units of value, the projectile + * is moved 1 game square towards the end position. Interpolate between 0-128 for + * units smaller than 1 game square. + * This is commonly set to 128 to make a projectile appear as if it's flying + * straight down, as the projectile will not render if its defined start and + * end coords are equal. So, in order to avoid that, one solution is to put the + * end coordinate 1 game square away from the start in a cardinal direction, + * and set the value of this property to 128 - ensuring that the projectile + * will appear to fly completely vertically, with no horizontal movement whatsoever. + * @property sourceIndex the index of the pathing entity from whom the projectile comes. + * If the value is 0, the projectile will not be locked to any source entity. + * + * If the source avatar is a player, set the value as `-(index + 1)` + * + * If the source avatar is a NPC, set the value as `(index + 1)` + * @property targetIndex the index of the pathing entity at whom the projectile is shot. + * If the value is 0, the projectile will not be locked to any target entity. + * + * If the target avatar is a player, set the value as `-(index + 1)` + * + * If the target avatar is a NPC, set the value as `(index + 1)` + * @property xInZone the start x coordinate of the projectile within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the start z coordinate of the projectile within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property end the end coordinate where the projectile will arrive at when not locked onto a target. + */ +@Suppress("DuplicatedCode") +public class MapProjAnimV2 private constructor( + private val _id: UShort, + private val _startHeight: UShort, + private val _endHeight: UShort, + private val _startTime: UShort, + private val _endTime: UShort, + private val _angle: UByte, + private val _progress: UShort, + public val sourceIndex: Int, + public val targetIndex: Int, + private val coordInZone: CoordInZone, + public val end: CoordGrid, +) : ZoneProt { + public constructor( + id: Int, + startHeight: Int, + endHeight: Int, + startTime: Int, + endTime: Int, + angle: Int, + progress: Int, + sourceIndex: Int, + targetIndex: Int, + xInZone: Int, + zInZone: Int, + end: CoordGrid, + ) : this( + id.toUShort(), + startHeight.toUShort(), + endHeight.toUShort(), + startTime.toUShort(), + endTime.toUShort(), + angle.toUByte(), + progress.toUShort(), + sourceIndex, + targetIndex, + CoordInZone(xInZone, zInZone), + end, + ) + + public constructor( + id: Int, + startHeight: Int, + endHeight: Int, + startTime: Int, + endTime: Int, + angle: Int, + progress: Int, + sourceIndex: Int, + targetIndex: Int, + xInZone: Int, + zInZone: Int, + endX: Int, + endZ: Int, + endLevel: Int, + ) : this( + id.toUShort(), + startHeight.toUShort(), + endHeight.toUShort(), + startTime.toUShort(), + endTime.toUShort(), + angle.toUByte(), + progress.toUShort(), + sourceIndex, + targetIndex, + CoordInZone(xInZone, zInZone), + CoordGrid(endLevel, endX, endZ), + ) + + public val id: Int + get() = _id.toInt() + public val startHeight: Int + get() = _startHeight.toInt() + public val endHeight: Int + get() = _endHeight.toInt() + public val startTime: Int + get() = _startTime.toInt() + public val endTime: Int + get() = _endTime.toInt() + public val angle: Int + get() = _angle.toInt() + public val progress: Int + get() = _progress.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.MAP_PROJANIM_V2 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MapProjAnimV2 + + if (_id != other._id) return false + if (_startHeight != other._startHeight) return false + if (_endHeight != other._endHeight) return false + if (_startTime != other._startTime) return false + if (_endTime != other._endTime) return false + if (_angle != other._angle) return false + if (_progress != other._progress) return false + if (sourceIndex != other.sourceIndex) return false + if (targetIndex != other.targetIndex) return false + if (coordInZone != other.coordInZone) return false + if (end != other.end) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _startHeight.hashCode() + result = 31 * result + _endHeight.hashCode() + result = 31 * result + _startTime.hashCode() + result = 31 * result + _endTime.hashCode() + result = 31 * result + _angle.hashCode() + result = 31 * result + _progress.hashCode() + result = 31 * result + sourceIndex.hashCode() + result = 31 * result + targetIndex.hashCode() + result = 31 * result + coordInZone.hashCode() + result = 31 * result + end.hashCode() + return result + } + + override fun toString(): String = + "MapProjAnimV2(" + + "id=$id, " + + "startHeight=$startHeight, " + + "endHeight=$endHeight, " + + "startTime=$startTime, " + + "endTime=$endTime, " + + "angle=$angle, " + + "progress=$progress, " + + "sourceIndex=$sourceIndex, " + + "targetIndex=$targetIndex, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "end=$end" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjAdd.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjAdd.kt new file mode 100644 index 000000000..575a27167 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjAdd.kt @@ -0,0 +1,155 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Obj add packets are used to spawn an obj on the ground. + * + * Ownership table: + * ``` + * | Id | Ownership Type | + * |----|:--------------:| + * | 0 | None | + * | 1 | Self Player | + * | 2 | Other Player | + * | 3 | Group Ironman | + * ``` + * + * @property id the id of the obj config + * @property quantity the quantity of the obj to be spawned + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property opFlags the right-click options enabled on this obj. + * Use the [net.rsprot.protocol.game.outgoing.util.OpFlags] helper object to create these + * bitpacked values which can be passed into it. + * @property timeUntilPublic how many game cycles until the obj turns public. + * This property is only used on the C++-based clients. + * @property timeUntilDespawn how many game cycles until the obj disappears. + * This property is only used on the C++-based clients. + * @property ownershipType the type of ownership of this obj (see table above). + * This property is only used on the C++-based clients. + * @property neverBecomesPublic whether the item turns public in the future. + * This property is only used on the c++-based clients. + */ +@Suppress("DuplicatedCode") +public class ObjAdd private constructor( + private val _id: UShort, + public val quantity: Int, + private val coordInZone: CoordInZone, + public val opFlags: Byte, + private val _timeUntilPublic: UShort, + private val _timeUntilDespawn: UShort, + private val _ownershipType: UByte, + public val neverBecomesPublic: Boolean, +) : ZoneProt { + public constructor( + id: Int, + quantity: Int, + xInZone: Int, + zInZone: Int, + opFlags: Byte, + timeUntilPublic: Int, + timeUntilDespawn: Int, + ownershipType: Int, + neverBecomesPublic: Boolean, + ) : this( + id.toUShort(), + quantity, + CoordInZone(xInZone, zInZone), + opFlags, + timeUntilPublic.toUShort(), + timeUntilDespawn.toUShort(), + ownershipType.toUByte(), + neverBecomesPublic, + ) + + /** + * A helper constructor for the JVM-based clients, as these clients + * do not utilize the [timeUntilPublic], [timeUntilDespawn], [ownershipType] and + * [neverBecomesPublic] properties. + */ + public constructor( + id: Int, + quantity: Int, + xInZone: Int, + zInZone: Int, + opFlags: Byte, + ) : this( + id, + quantity, + xInZone, + zInZone, + opFlags, + timeUntilPublic = 0, + timeUntilDespawn = 0, + ownershipType = 0, + neverBecomesPublic = false, + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + public val timeUntilPublic: Int + get() = _timeUntilPublic.toInt() + public val timeUntilDespawn: Int + get() = _timeUntilDespawn.toInt() + public val ownershipType: Int + get() = _ownershipType.toInt() + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_ADD + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjAdd + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordInZone != other.coordInZone) return false + if (opFlags != other.opFlags) return false + if (_timeUntilPublic != other._timeUntilPublic) return false + if (_timeUntilDespawn != other._timeUntilDespawn) return false + if (_ownershipType != other._ownershipType) return false + if (neverBecomesPublic != other.neverBecomesPublic) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordInZone.hashCode() + result = 31 * result + opFlags.hashCode() + result = 31 * result + _timeUntilPublic.hashCode() + result = 31 * result + _timeUntilDespawn.hashCode() + result = 31 * result + _ownershipType.hashCode() + result = 31 * result + neverBecomesPublic.hashCode() + return result + } + + override fun toString(): String = + "ObjAdd(" + + "id=$id, " + + "quantity=$quantity, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone, " + + "opFlags=$opFlags, " + + "timeUntilPublic=$timeUntilPublic, " + + "timeUntilDespawn=$timeUntilDespawn, " + + "ownershipType=$ownershipType" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCount.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCount.kt new file mode 100644 index 000000000..e98aed192 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCount.kt @@ -0,0 +1,85 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Obj count is a packet used to update the quantity of an obj that's already + * spawned into the build area. This is only done for objs which are private + * to a specific user - doing so merges the stacks together into one rather + * than having two distinct stacks of the same item. + * @property id the id of the obj to merge + * @property oldQuantity the old quantity of the obj to find, if no obj + * by this quantity is found, this packet has no effect client-side + * @property newQuantity the new quantity to be set to this obj + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class ObjCount private constructor( + private val _id: UShort, + public val oldQuantity: Int, + public val newQuantity: Int, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + oldQuantity: Int, + newQuantity: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + oldQuantity, + newQuantity, + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_COUNT + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjCount + + if (_id != other._id) return false + if (oldQuantity != other.oldQuantity) return false + if (newQuantity != other.newQuantity) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + oldQuantity + result = 31 * result + newQuantity + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String = + "ObjCount(" + + "id=$id, " + + "oldQuantity=$oldQuantity, " + + "newQuantity=$newQuantity, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCustomise.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCustomise.kt new file mode 100644 index 000000000..630f3c676 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjCustomise.kt @@ -0,0 +1,120 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt +import net.rsprot.protocol.message.toIntOrMinusOne + +/** + * Obj customise is a packet that allows the server to modify an item on the ground, by either changing + * the model, the colours and the textures of it. + * @property id the id of the obj to update + * @property quantity the quantity of the obj to update + * @property model the model id to assign to this obj + * @property recolIndex the index of the colour to override + * @property recol the colour value to assign at the [recolIndex] index + * @property retexIndex the index of the texture to override + * @property retex the texture value to assign at the [retexIndex] index + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class ObjCustomise private constructor( + private val _id: UShort, + public val quantity: Int, + private val _model: UShort, + private val _recolIndex: Short, + private val _recol: Short, + private val _retexIndex: Short, + private val _retex: Short, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + quantity: Int, + model: Int, + recolIndex: Int, + recol: Int, + retexIndex: Int, + retex: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + quantity, + model.toUShort(), + recolIndex.toShort(), + recol.toShort(), + retexIndex.toShort(), + retex.toShort(), + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val model: Int + get() = _model.toIntOrMinusOne() + public val recolIndex: Int + get() = _recolIndex.toInt() + public val recol: Int + get() = _recol.toInt() + public val retexIndex: Int + get() = _retexIndex.toInt() + public val retex: Int + get() = _retex.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_CUSTOMISE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ObjCustomise) return false + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (_model != other._model) return false + if (_recolIndex != other._recolIndex) return false + if (_recol != other._recol) return false + if (_retexIndex != other._retexIndex) return false + if (_retex != other._retex) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + _model.hashCode() + result = 31 * result + _recolIndex + result = 31 * result + _recol + result = 31 * result + _retexIndex + result = 31 * result + _retex + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String { + return "ObjCustomise(" + + "id=$id, " + + "quantity=$quantity, " + + "model=$model, " + + "recolIndex=$recolIndex, " + + "recol=$recol, " + + "retexIndex=$retexIndex, " + + "retex=$retex, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjDel.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjDel.kt new file mode 100644 index 000000000..11219cf2c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjDel.kt @@ -0,0 +1,78 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Obj del packets are used to delete an existing obj from the build area, + * assuming it exists in the first place. + * @property id the id of the obj to delete. Note that the client does bitwise-and + * on the id to cap it to the lowest 15 bits, meaning the maximum id that can be + * transmitted is 32767. + * @property quantity the quantity of the obj to be deleted. If there is no obj + * with this quantity, nothing will be deleted. + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class ObjDel private constructor( + private val _id: UShort, + public val quantity: Int, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + quantity: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + quantity, + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_DEL + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjDel + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String = + "ObjDel(" + + "id=$id, " + + "quantity=$quantity, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjEnabledOps.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjEnabledOps.kt new file mode 100644 index 000000000..4acb26ac4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjEnabledOps.kt @@ -0,0 +1,79 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Obj enabled ops is used to change the right-click options on an obj + * on the ground. This packet is currently unused in OldSchool RuneScape. + * It works by finding the first obj in the stack with the provided [id], + * and modifying the right-click ops on that. It does not verify quantity. + * @property id the id of the obj that needs to get its ops changed + * @property opFlags the right-click options to set enabled on that obj. + * Use the [net.rsprot.protocol.game.outgoing.util.OpFlags] helper object to create these + * bitpacked values which can be passed into it. + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class ObjEnabledOps private constructor( + private val _id: UShort, + public val opFlags: Byte, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + opFlags: Byte, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + opFlags, + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_ENABLED_OPS + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjEnabledOps + + if (_id != other._id) return false + if (opFlags != other.opFlags) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + opFlags.hashCode() + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String = + "ObjEnabledOps(" + + "id=$id, " + + "opFlags=$opFlags, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjUncustomise.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjUncustomise.kt new file mode 100644 index 000000000..fac12dcd4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ObjUncustomise.kt @@ -0,0 +1,73 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Obj uncustomise resets any customisations done to an obj via the [ObjCustomise] packet. + * @property id the id of the obj to update + * @property quantity the quantity of the obj to update + * @property xInZone the x coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the obj within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +public class ObjUncustomise private constructor( + private val _id: UShort, + public val quantity: Int, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + quantity: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + quantity, + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.OBJ_UNCUSTOMISE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ObjUncustomise) return false + + if (_id != other._id) return false + if (quantity != other.quantity) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + quantity + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String { + return "ObjCustomise(" + + "id=$id, " + + "quantity=$quantity, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/SoundArea.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/SoundArea.kt new file mode 100644 index 000000000..c1e1d6978 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/SoundArea.kt @@ -0,0 +1,124 @@ +package net.rsprot.protocol.game.outgoing.zone.payload + +import net.rsprot.protocol.ServerProtCategory +import net.rsprot.protocol.game.outgoing.GameServerProtCategory +import net.rsprot.protocol.game.outgoing.zone.payload.util.CoordInZone +import net.rsprot.protocol.internal.game.outgoing.codec.zone.payload.OldSchoolZoneProt +import net.rsprot.protocol.message.ZoneProt + +/** + * Sound area packed is sent to play a sound effect at a specific coord. + * Any players within [range] tiles of the destination coord will + * hear this sound effect played, if they have sound effects enabled. + * The volume will change according to the player's distance to the + * origin coord of the sound effect itself. + * It is worth noting there is a maximum quantity of 50 area sound effects + * that can play concurrently in the client across all the zones. + * Therefore, a potential optimization one can do is prevent appending + * any more area sound effects once the quantity has reached 50 in a zone. + * @property id the id of the sound effect to play + * @property delay the delay in client cycles (20ms/cc) until the + * sound effect starts playing + * @property loops how many loops the sound effect should do. + * If the [loops] property is 0, the sound effect will not play. + * @property range the radius from the originating coord how far the sound + * effect can be heard. Note that the client ignores the 4 higher bits of + * this value, meaning the maximum radius is 31 tiles - anything above has + * no effect. + * @property dropOffRange the size of the origin. In most cases, this should be + * a value of 1. However, if a larger value is provided, it means the + * client will treat the south-western coord provided here as the + * south-western corner of the 'box' that is made with this size in mind, + * for the purpose of having an evenly-spreading volume around this + * element. + * This size property is primarily used for larger NPCs, to make their + * sound effects flow out smoothly from all sides. + * @property xInZone the x coordinate of the sound effect within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + * @property zInZone the z coordinate of the sound effect within the zone it is in, + * a value in range of 0 to 7 (inclusive) is expected. Any bits outside that are ignored. + */ +@Suppress("DuplicatedCode") +public class SoundArea private constructor( + private val _id: UShort, + private val _delay: UByte, + private val _loops: UByte, + private val _range: UByte, + private val _dropOffRange: UByte, + private val coordInZone: CoordInZone, +) : ZoneProt { + public constructor( + id: Int, + delay: Int, + loops: Int, + radius: Int, + size: Int, + xInZone: Int, + zInZone: Int, + ) : this( + id.toUShort(), + delay.toUByte(), + loops.toUByte(), + radius.toUByte(), + size.toUByte(), + CoordInZone(xInZone, zInZone), + ) + + public val id: Int + get() = _id.toInt() + public val delay: Int + get() = _delay.toInt() + public val loops: Int + get() = _loops.toInt() + public val range: Int + get() = _range.toInt() + public val dropOffRange: Int + get() = _dropOffRange.toInt() + public val xInZone: Int + get() = coordInZone.xInZone + public val zInZone: Int + get() = coordInZone.zInZone + + public val coordInZonePacked: Int + get() = coordInZone.packed.toInt() + override val category: ServerProtCategory + get() = GameServerProtCategory.HIGH_PRIORITY_PROT + override val protId: Int = OldSchoolZoneProt.SOUND_AREA + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SoundArea + + if (_id != other._id) return false + if (_delay != other._delay) return false + if (_loops != other._loops) return false + if (_range != other._range) return false + if (_dropOffRange != other._dropOffRange) return false + if (coordInZone != other.coordInZone) return false + + return true + } + + override fun hashCode(): Int { + var result = _id.hashCode() + result = 31 * result + _delay.hashCode() + result = 31 * result + _loops.hashCode() + result = 31 * result + _range.hashCode() + result = 31 * result + _dropOffRange.hashCode() + result = 31 * result + coordInZone.hashCode() + return result + } + + override fun toString(): String = + "SoundArea(" + + "id=$id, " + + "delay=$delay, " + + "loops=$loops, " + + "range=$range, " + + "dropOffRange=$dropOffRange, " + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ZoneProtTypeAliases.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ZoneProtTypeAliases.kt new file mode 100644 index 000000000..a5ee91c63 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/ZoneProtTypeAliases.kt @@ -0,0 +1,9 @@ +@file:Suppress("ktlint:standard:filename") + +package net.rsprot.protocol.game.outgoing.zone.payload + +@Deprecated( + message = "Deprecated. Use LocAddChangeV2.", + replaceWith = ReplaceWith("LocAddChangeV2"), +) +public typealias LocAddChange = LocAddChangeV2 diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInBuildArea.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInBuildArea.kt new file mode 100644 index 000000000..26bbe99f4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInBuildArea.kt @@ -0,0 +1,65 @@ +package net.rsprot.protocol.game.outgoing.zone.payload.util + +/** + * Coord in build-area is a helper class to compress the data used to transmit + * build-area coords to the client, primarily in *-specific packets. + * These packets will separate the south-western zone X/Z coordinates, + * and the x/z in-zone coordinates into separate properties. + * @property zoneX the south-western x coordinate of the zone (multiples of 8 value) + * @property xInZone the x coordinate within the zone (0-7 value) + * @property zoneZ the south-western z coordinate of the zone (multiples of 8 value) + * @property zInZone the z coordinate within the zone (0-7 value) + * @property packedMedium the coordinates bitpacked into a 24-bit integer, + * as this is how they tend to be transmitted to the client. + */ +@JvmInline +internal value class CoordInBuildArea private constructor( + private val packedShort: UShort, +) { + constructor( + zoneX: Int, + xInZone: Int, + zoneZ: Int, + zInZone: Int, + ) : this( + ((zoneX and 0x7.inv() or (xInZone and 0x7)) shl 8) + .or((zoneZ and 0x7.inv()) or (zInZone and 0x7)) + .toUShort(), + ) + + constructor( + xinBuildArea: Int, + zInBuildArea: Int, + ) : this( + ((xinBuildArea and 0xFF) shl 8) + .or(zInBuildArea) + .toUShort(), + ) + + val zoneX: Int + get() = packedShort.toInt() ushr 8 and 0xF8 + val xInZone: Int + get() = packedShort.toInt() ushr 8 and 0x7 + val zoneZ: Int + get() = packedShort.toInt() and 0xF8 + val zInZone: Int + get() = packedShort.toInt() and 0x7 + + val xInBuildArea: Int + get() = packedShort.toInt() ushr 8 + val zInBuildArea: Int + get() = packedShort.toInt() and 0xFF + + val packedMedium: Int + get() = + (zoneX shl 16) + .or(zoneZ shl 8) + .or(xInZone shl 4) + .or(zInZone) + + override fun toString(): String = + "CoordInBuildArea(" + + "xInBuildArea=$xInBuildArea, " + + "zInBuildArea=$zInBuildArea" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInZone.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInZone.kt new file mode 100644 index 000000000..2dabd5d54 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/CoordInZone.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.zone.payload.util + +@JvmInline +internal value class CoordInZone private constructor( + val packed: UByte, +) { + constructor( + xInZone: Int, + zInZone: Int, + ) : this( + (xInZone and 0x7 shl 4) + .or(zInZone and 0x7) + .toUByte(), + ) + + val xInZone: Int + get() = packed.toInt() ushr 4 and 0x7 + val zInZone: Int + get() = packed.toInt() and 0x7 + + override fun toString(): String = + "CoordInZone(" + + "xInZone=$xInZone, " + + "zInZone=$zInZone" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/LocProperties.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/LocProperties.kt new file mode 100644 index 000000000..df638ba7e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/zone/payload/util/LocProperties.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.game.outgoing.zone.payload.util + +@JvmInline +internal value class LocProperties private constructor( + val packed: UByte, +) { + constructor( + shape: Int, + rotation: Int, + ) : this( + (shape and 0x1F shl 2) + .or(rotation and 0x3) + .toUByte(), + ) + + val shape: Int + get() = packed.toInt() ushr 2 and 0x1F + val rotation: Int + get() = packed.toInt() and 0x3 + + override fun toString(): String = + "LocProperties(" + + "shape=$shape, " + + "rotation=$rotation" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/Js5GroupRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/Js5GroupRequest.kt new file mode 100644 index 000000000..9c8513987 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/Js5GroupRequest.kt @@ -0,0 +1,9 @@ +package net.rsprot.protocol.js5.incoming + +public sealed interface Js5GroupRequest { + public val archiveId: Int + public val groupId: Int + + public val bitpacked: Int + get() = groupId or (archiveId shl 16) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PrefetchRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PrefetchRequest.kt new file mode 100644 index 000000000..1a477454e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PrefetchRequest.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.js5.incoming + +import net.rsprot.protocol.message.IncomingJs5Message + +public class PrefetchRequest( + private val _archiveId: UByte, + private val _groupId: UShort, +) : IncomingJs5Message, + Js5GroupRequest { + override val archiveId: Int + get() = _archiveId.toInt() + override val groupId: Int + get() = _groupId.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PrefetchRequest) return false + + if (_archiveId != other._archiveId) return false + if (_groupId != other._groupId) return false + + return true + } + + override fun hashCode(): Int { + var result = _archiveId.hashCode() + result = 31 * result + _groupId.hashCode() + return result + } + + override fun toString(): String = + "PrefetchRequest(" + + "archiveId=$archiveId, " + + "groupId=$groupId" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeHigh.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeHigh.kt new file mode 100644 index 000000000..8d565121e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeHigh.kt @@ -0,0 +1,5 @@ +package net.rsprot.protocol.js5.incoming + +import net.rsprot.protocol.message.IncomingJs5Message + +public data object PriorityChangeHigh : IncomingJs5Message diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeLow.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeLow.kt new file mode 100644 index 000000000..c3fbf790a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/PriorityChangeLow.kt @@ -0,0 +1,5 @@ +package net.rsprot.protocol.js5.incoming + +import net.rsprot.protocol.message.IncomingJs5Message + +public data object PriorityChangeLow : IncomingJs5Message diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/UrgentRequest.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/UrgentRequest.kt new file mode 100644 index 000000000..bd0388981 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/UrgentRequest.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.js5.incoming + +import net.rsprot.protocol.message.IncomingJs5Message + +public class UrgentRequest( + private val _archiveId: UByte, + private val _groupId: UShort, +) : IncomingJs5Message, + Js5GroupRequest { + override val archiveId: Int + get() = _archiveId.toInt() + override val groupId: Int + get() = _groupId.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UrgentRequest) return false + + if (_archiveId != other._archiveId) return false + if (_groupId != other._groupId) return false + + return true + } + + override fun hashCode(): Int { + var result = _archiveId.hashCode() + result = 31 * result + _groupId.hashCode() + return result + } + + override fun toString(): String = + "UrgentRequest(" + + "archiveId=$archiveId, " + + "groupId=$groupId" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/XorChange.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/XorChange.kt new file mode 100644 index 000000000..2005b312d --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/incoming/XorChange.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.js5.incoming + +import net.rsprot.protocol.message.IncomingJs5Message + +public class XorChange( + public val key: Int, +) : IncomingJs5Message { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is XorChange) return false + + if (key != other.key) return false + + return true + } + + override fun hashCode(): Int = key + + override fun toString(): String = "XorChange(key=$key)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/outgoing/Js5GroupResponse.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/outgoing/Js5GroupResponse.kt new file mode 100644 index 000000000..3b98829c4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/js5/outgoing/Js5GroupResponse.kt @@ -0,0 +1,41 @@ +package net.rsprot.protocol.js5.outgoing + +import io.netty.buffer.ByteBuf +import io.netty.buffer.DefaultByteBufHolder +import net.rsprot.protocol.message.OutgoingJs5Message + +/** + * Js5 group responses are used to feed the cache to the client through the server. + * @param buffer the byte buffer that is used for the response + */ +public class Js5GroupResponse( + buffer: ByteBuf, + public val key: Int, +) : DefaultByteBufHolder(buffer), + OutgoingJs5Message { + override fun estimateSize(): Int { + return content().readableBytes() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Js5GroupResponse) return false + if (!super.equals(other)) return false + + if (key != other.key) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + key + return result + } + + override fun toString(): String { + return "Js5GroupResponse(" + + "key=$key" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameLogin.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameLogin.kt new file mode 100644 index 000000000..4a57f6255 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameLogin.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.loginprot.incoming.util.AuthenticationType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlockDecodingFunction +import net.rsprot.protocol.message.IncomingLoginMessage + +public typealias LoginDecodingFunction = LoginBlockDecodingFunction + +public data class GameLogin( + public val buffer: JagByteBuf, + public val decoder: LoginDecodingFunction, +) : IncomingLoginMessage diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameReconnect.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameReconnect.kt new file mode 100644 index 000000000..c87606be5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/GameReconnect.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.loginprot.incoming.util.LoginBlockDecodingFunction +import net.rsprot.protocol.message.IncomingLoginMessage + +public typealias ReconnectDecodingFunction = LoginBlockDecodingFunction + +public data class GameReconnect( + public val buffer: JagByteBuf, + public val decoder: ReconnectDecodingFunction, +) : IncomingLoginMessage diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitGameConnection.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitGameConnection.kt new file mode 100644 index 000000000..49e985cbb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitGameConnection.kt @@ -0,0 +1,5 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.protocol.message.IncomingLoginMessage + +public data object InitGameConnection : IncomingLoginMessage diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitJs5RemoteConnection.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitJs5RemoteConnection.kt new file mode 100644 index 000000000..b6db6be09 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/InitJs5RemoteConnection.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.protocol.message.IncomingLoginMessage + +public class InitJs5RemoteConnection( + public val revision: Int, + public val seed: IntArray, +) : IncomingLoginMessage { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as InitJs5RemoteConnection + + if (revision != other.revision) return false + if (!seed.contentEquals(other.seed)) return false + + return true + } + + override fun hashCode(): Int { + var result = revision + result = 31 * result + seed.contentHashCode() + return result + } + + override fun toString(): String = + "InitJs5RemoteConnection(" + + "revision=$revision, " + + "seed=${seed.contentToString()}" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/ProofOfWorkReply.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/ProofOfWorkReply.kt new file mode 100644 index 000000000..6b03e2653 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/ProofOfWorkReply.kt @@ -0,0 +1,7 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.protocol.message.IncomingLoginMessage + +public data class ProofOfWorkReply( + public val result: Long, +) : IncomingLoginMessage diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/RemainingBetaArchives.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/RemainingBetaArchives.kt new file mode 100644 index 000000000..285e3313b --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/RemainingBetaArchives.kt @@ -0,0 +1,42 @@ +package net.rsprot.protocol.loginprot.incoming + +import net.rsprot.protocol.message.IncomingLoginMessage + +public class RemainingBetaArchives( + internal val crc: IntArray, +) : IncomingLoginMessage { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RemainingBetaArchives + + return crc.contentEquals(other.crc) + } + + override fun hashCode(): Int = crc.contentHashCode() + + override fun toString(): String = "RemainingBetaArchives(crc=${crc.contentToString()})" + + public companion object { + public val protectedArchives: List = + listOf( + 0, + 1, + 2, + 3, + 5, + 7, + 9, + 11, + 12, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + ) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/NopProofOfWorkProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/NopProofOfWorkProvider.kt new file mode 100644 index 000000000..c39d13ae8 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/NopProofOfWorkProvider.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.loginprot.incoming.pow + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * A no-operation proof of work provider, allowing one to skip proof of work entirely. + */ +public object NopProofOfWorkProvider : + ProofOfWorkProvider { + override fun provide( + hostAddress: String, + header: LoginBlock.Header, + ): ProofOfWork? { + return null + } + + public object NopChallengeType : ChallengeType { + override val id: Int = 0 + + override fun encode(buffer: JagByteBuf) { + // nop + } + + override fun estimateMessageSize(): Int { + return 0 + } + } + + public object NopChallengeMetaData : ChallengeMetaData +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWork.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWork.kt new file mode 100644 index 000000000..0934ff2cd --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWork.kt @@ -0,0 +1,42 @@ +package net.rsprot.protocol.loginprot.incoming.pow + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeVerifier + +/** + * Proof of work is a procedure during login to attempt to throttle login requests from a single source, + * by requiring them to do CPU-bound work before accepting the login. + * @property challengeType the type of the challenge to require the client to solve + * @property challengeVerifier the verifier of that challenge, to ensure the client did complete + * the world successfully + */ +@Suppress("MemberVisibilityCanBePrivate") +public class ProofOfWork, in MetaData : ChallengeMetaData>( + public val challengeType: T, + public val challengeVerifier: ChallengeVerifier, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ProofOfWork<*, *> + + if (challengeType != other.challengeType) return false + if (challengeVerifier != other.challengeVerifier) return false + + return true + } + + override fun hashCode(): Int { + var result = challengeType.hashCode() + result = 31 * result + challengeVerifier.hashCode() + return result + } + + override fun toString(): String = + "ProofOfWork(" + + "challengeType=$challengeType, " + + "challengeVerifier=$challengeVerifier" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWorkProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWorkProvider.kt new file mode 100644 index 000000000..df8d2dcdc --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/ProofOfWorkProvider.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.loginprot.incoming.pow + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * An interface to return proof of work implementations based on the input ip. + */ +public fun interface ProofOfWorkProvider, in MetaData : ChallengeMetaData> { + /** + * Provides a proof of work instance for a given [String]. + * @param inetAddress the IP from which the client is connecting. + * @param header header the login block header, containing initial information about the + * request, such as the revision and the client type connecting. + * @return a proof of work instance that the client needs to solve, or null if it should be skipped + */ + public fun provide( + hostAddress: String, + header: LoginBlock.Header, + ): ProofOfWork? +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/SingleTypeProofOfWorkProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/SingleTypeProofOfWorkProvider.kt new file mode 100644 index 000000000..88b8d20d0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/SingleTypeProofOfWorkProvider.kt @@ -0,0 +1,33 @@ +package net.rsprot.protocol.loginprot.incoming.pow + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeGenerator +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaDataProvider +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeVerifier +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * A single type proof of work provider is used to always return proof of work instances + * of a single specific type. + * @property metaDataProvider the provider used to return instances of metadata for the + * challenges. + * @property challengeGenerator the generator that will create a new proof of work challenge + * based on the input metadata. + * @property challengeVerifier the verifier that will check if the answer sent by the client + * is correct. + */ +public class SingleTypeProofOfWorkProvider, in MetaData : ChallengeMetaData>( + private val metaDataProvider: ChallengeMetaDataProvider, + private val challengeGenerator: ChallengeGenerator, + private val challengeVerifier: ChallengeVerifier, +) : ProofOfWorkProvider { + override fun provide( + hostAddress: String, + header: LoginBlock.Header, + ): ProofOfWork { + val metadata = metaDataProvider.provide(hostAddress, header) + val challenge = challengeGenerator.generate(metadata) + return ProofOfWork(challenge, challengeVerifier) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeGenerator.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeGenerator.kt new file mode 100644 index 000000000..3533b70e7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeGenerator.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +/** + * A challenge generator used to construct a challenge out of the provided metadata. + */ +public fun interface ChallengeGenerator> { + /** + * A function to generate a challenge out of the provided metadata. + * @param input the metadata input necessary to generate a challenge + * @return a challenge generated out of the metadata + */ + public fun generate(input: MetaData): Type +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaData.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaData.kt new file mode 100644 index 000000000..388bb05c0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaData.kt @@ -0,0 +1,6 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +/** + * A common binding interface for metadata necessary to pass into the challenge constructors. + */ +public interface ChallengeMetaData diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaDataProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaDataProvider.kt new file mode 100644 index 000000000..4abd9308e --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeMetaDataProvider.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * A challenge metadata provider is used to generate a metadata necessary to construct a challenge. + */ +public interface ChallengeMetaDataProvider { + /** + * Provides a metadata instance for a challenge, using the ip as the input parameter. + * @param inetAddress the IP from which the user is connecting to the server. + * This is provided in case an implementation which scales with the number of requests + * from a given host is desired. + * @param header the login block header, containing initial information about the + * request, such as the revision and the client type connecting. + * @return the metadata object necessary to construct a challenge. + */ + public fun provide( + hostAddress: String, + header: LoginBlock.Header, + ): T +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeType.kt new file mode 100644 index 000000000..66610aafb --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeType.kt @@ -0,0 +1,30 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +import net.rsprot.buffer.JagByteBuf + +/** + * A common binding interface for challenge types. + * Currently, the client only supports SHA-256 as a challenge, but it is set up to + * support other types with ease. + * @param MetaData the metadata necessary to construct a challenge of this type. + * @property id the id of the challenge, used by the client to identify what challenge + * solver to use. + */ +public interface ChallengeType { + public val id: Int + + /** + * A function to encode the given challenge into the byte buffer that the client expects. + * The role of encoding is moved over to the implementation as the server can provide + * its own implementations, should the client support any. + * @param buffer the buffer into which to encode the challenge + */ + public fun encode(buffer: JagByteBuf) + + /** + * Estimates the size of the message, allowing Netty to accurately track the number of bytes + * writing it would require. + * @return the number of bytes to initialize with. + */ + public fun estimateMessageSize(): Int +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeVerifier.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeVerifier.kt new file mode 100644 index 000000000..e47bf85ff --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeVerifier.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +/** + * A challenge verifier is used to check the work that the client did. + * The general idea here is that the client has to perform the work N times, where N is + * pseudo-random, while the server only has to do that same work one time - to verify the + * result that the client sent. The complexity of the work to perform is configurable by the + * server. + * @param T the challenge type to verify + */ +public interface ChallengeVerifier> { + /** + * Verifies the work performed by the client. + * @param result the 64-bit long response value from the client + * @param challenge the challenge to verify using the [result] provided. + * @return whether the challenge is solved using the [result] provided. + */ + public fun verify( + result: Long, + challenge: T, + ): Boolean +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeWorker.kt new file mode 100644 index 000000000..f6c070f20 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/ChallengeWorker.kt @@ -0,0 +1,25 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +import java.util.concurrent.CompletableFuture + +/** + * A worker is used to perform the verifications of the data sent by the client for our + * proof of work requests. While the work itself is relatively cheap, servers may wish + * to perform the work on other threads - this interface allows doing that. + */ +public interface ChallengeWorker { + /** + * Verifies the result sent by the client. + * @param result the byte buffer containing the result data sent by the client + * @param challenge the challenge the client had to solve + * @param verifier the verifier used to check the work done by the client for out challenge + * @return a future object containing the result of the work, or an exception. + * If the future doesn't return immediately, there will be a 30-second timeout applied to it, + * after which the work will be concluded failed. + */ + public fun , V : ChallengeVerifier> verify( + result: Long, + challenge: T, + verifier: V, + ): CompletableFuture +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/DefaultChallengeWorker.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/DefaultChallengeWorker.kt new file mode 100644 index 000000000..2ae28c7b7 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/DefaultChallengeWorker.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges + +import java.util.concurrent.CompletableFuture + +/** + * The default challenge worker will perform the work on the calling thread. + * The SHA-256 challenges are fairly inexpensive and the overhead of switching threads + * is similar to the work itself done. + */ +public data object DefaultChallengeWorker : ChallengeWorker { + public override fun , V : ChallengeVerifier> verify( + result: Long, + challenge: T, + verifier: V, + ): CompletableFuture = + try { + CompletableFuture.completedFuture(verifier.verify(result, challenge)) + } catch (t: Throwable) { + CompletableFuture.failedFuture(t) + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ChallengeGenerator.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ChallengeGenerator.kt new file mode 100644 index 000000000..ad652c63a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ChallengeGenerator.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeGenerator +import java.math.BigInteger +import kotlin.random.Random + +/** + * The default SHA-256 challenge generator is used to generate challenges which align + * up with what OldSchool RuneScape is generating, which is a combination of epoch time millis, + * the world id and a 495-byte [BigInteger] that is turned into a hexadecimal string, + * which will have a length of 1004 or 1005 characters, depending on if the [BigInteger] was negative. + */ +public class DefaultSha256ChallengeGenerator : ChallengeGenerator { + override fun generate(input: Sha256MetaData): Sha256Challenge { + val randomData = Random.Default.nextBytes(RANDOM_DATA_LENGTH) + val hexSalt = BigInteger(randomData).toString(HEX_RADIX) + val salt = + java.lang.Long.toHexString(input.epochTimeMillis) + + Integer.toHexString(input.world) + + hexSalt + return Sha256Challenge( + input.version, + input.difficulty, + salt, + ) + } + + private companion object { + private const val RANDOM_DATA_LENGTH: Int = 495 + private const val HEX_RADIX: Int = 16 + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256MetaDataProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256MetaDataProvider.kt new file mode 100644 index 000000000..bf1c160c3 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256MetaDataProvider.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaDataProvider +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock + +/** + * The default SHA-256 metadata provider will return a metadata object + * that matches what OldSchool RuneScape sends. + * @property world the world that the client is connecting to. + */ +public class DefaultSha256MetaDataProvider( + private val world: Int, +) : ChallengeMetaDataProvider { + override fun provide( + hostAddress: String, + header: LoginBlock.Header, + ): Sha256MetaData = Sha256MetaData(world) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ProofOfWorkProvider.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ProofOfWorkProvider.kt new file mode 100644 index 000000000..6e0f18b80 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/DefaultSha256ProofOfWorkProvider.kt @@ -0,0 +1,23 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.protocol.loginprot.incoming.pow.ProofOfWorkProvider +import net.rsprot.protocol.loginprot.incoming.pow.SingleTypeProofOfWorkProvider + +/** + * A class to wrap the properties of a SHA-256 into a single instance. + * @property provider the SHA-256 proof of work provider. + */ +@Suppress("MemberVisibilityCanBePrivate") +public class DefaultSha256ProofOfWorkProvider private constructor( + public val provider: SingleTypeProofOfWorkProvider, +) : ProofOfWorkProvider by provider { + public constructor( + world: Int, + ) : this( + SingleTypeProofOfWorkProvider( + DefaultSha256MetaDataProvider(world), + DefaultSha256ChallengeGenerator(), + Sha256ChallengeVerifier(), + ), + ) +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256Challenge.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256Challenge.kt new file mode 100644 index 000000000..b3bc59a78 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256Challenge.kt @@ -0,0 +1,81 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType + +/** + * A SHA-256 challenge is a challenge which forces the client to find a hash which + * has at least [difficulty] number of leading zero bits in the hash. + * As hashing returns pseudo-random results, as a general rule of thumb, the work + * needed to solve a challenge doubles with each difficulty increase, since each + * bit can be either true or false, and the solution must have at least [difficulty] + * amount of false (zero) bits. + * Since the requirement is that there are at least [difficulty] amount of leading + * zero bits, these challenges aren't constrained to only having a single successful + * answer. + * @property version the version of hashcash to use, only `1` is supported. + * @property difficulty the difficulty of the challenge, as explained above, is the number + * of leading zero bits the hash must have for it to be considered successful. + * The default difficulty in OldSchool RuneScape is 18 as of writing this. + * When Proof of Work was first introduced, this value was 16. + * It is possible that the value gets dynamically increased as the pressure increases, + * or if there are a lot of requests from a single IP. + * @property salt the salt string that is the bulk of the input to hash. + * @property id the id of the challenge as identified by the client. + */ +public class Sha256Challenge( + public val version: Int, + public val difficulty: Int, + public val salt: String, +) : ChallengeType { + override val id: Int + get() = 0 + + override fun encode(buffer: JagByteBuf) { + buffer.p1(version) + buffer.p1(difficulty) + buffer.pjstr(salt, Charsets.UTF_8) + } + + override fun estimateMessageSize(): Int { + return Byte.SIZE_BYTES + + Byte.SIZE_BYTES + + salt.length + + Byte.SIZE_BYTES + } + + /** + * Gets the base string that is part of the input for the hash. + * A long will be appended to this base string at the end, which will additionally + * be the solution to the challenge. The full string of baseString + the long is what + * must result in [difficulty] number of leading zero bits after having been hashed. + * @return the base string used for the hashing input. + */ + public fun getBaseString(): String = + Integer.toHexString(this.version) + Integer.toHexString(this.difficulty) + this.salt + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Sha256Challenge) return false + + if (version != other.version) return false + if (difficulty != other.difficulty) return false + if (salt != other.salt) return false + + return true + } + + override fun hashCode(): Int { + var result = version + result = 31 * result + difficulty + result = 31 * result + salt.hashCode() + return result + } + + override fun toString(): String = + "Sha256Challenge(" + + "version=$version, " + + "difficulty=$difficulty, " + + "salt='$salt'" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256ChallengeVerifier.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256ChallengeVerifier.kt new file mode 100644 index 000000000..f0e1897b0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256ChallengeVerifier.kt @@ -0,0 +1,78 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeVerifier +import net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.hashing.DefaultSha256MessageDigestHashFunction +import net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.hashing.Sha256HashFunction + +/** + * The SHA-256 challenge verifier is a replica of the client's implementation of the + * SHA-256 proof of work verifier. + * @property hashFunction the function used to hash the input bytes, with the default + * implementation being the same as the client - making a new MessageDigest object + * for each hash. These objects are fairly cheap, though. + */ +public class Sha256ChallengeVerifier( + private val hashFunction: Sha256HashFunction = DefaultSha256MessageDigestHashFunction, +) : ChallengeVerifier { + override fun verify( + result: Long, + challenge: Sha256Challenge, + ): Boolean { + val baseString = challenge.getBaseString() + val builder = StringBuilder(baseString) + builder.append(java.lang.Long.toHexString(result)) + val utf8ByteArray = + builder + .toString() + .toByteArray(Charsets.UTF_8) + val hash = hashFunction.hash(utf8ByteArray) + return leadingZeros(hash) >= challenge.difficulty + } + + /** + * Counts the number of leading zero bits in the [byteArray]. + * @param byteArray the byte array to check for leading zero bits. + * @return the number of leading zero bits in the byte array. + */ + private fun leadingZeros(byteArray: ByteArray): Int { + var numBits = 0 + for (byte in byteArray) { + val bitCount = leadingZeros(byte) + numBits += bitCount + if (bitCount != Byte.SIZE_BITS) { + break + } + } + return numBits + } + + /** + * Gets the number of leading zero bits in the provided [byte]. + * @return the number of leading zero bits in the byte. + */ + private fun leadingZeros(byte: Byte): Int { + var value = byte.toInt() and 0xFF + if (value == 0) { + return Byte.SIZE_BITS + } + var numBits = 0 + while (value and 0x80 == 0) { + numBits++ + value = value shl 1 + } + return numBits + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Sha256ChallengeVerifier) return false + + if (hashFunction != other.hashFunction) return false + + return true + } + + override fun hashCode(): Int = hashFunction.hashCode() + + override fun toString(): String = "Sha256ChallengeVerifier(hashFunction=$hashFunction)" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256MetaData.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256MetaData.kt new file mode 100644 index 000000000..038b1677a --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/Sha256MetaData.kt @@ -0,0 +1,51 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256 + +import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData + +/** + * The SHA-256 metadata is what the default SHA-256 implementation requires in order + * to construct new challenges. + * @property world the world that the client is connecting to. The world id is the second argument + * to the string that will be hashed. + * @property difficulty the difficulty of the challenge, which is the number of leading zero bits + * that the hash must have for it to be considered successful. + * @property epochTimeMillis the epoch time milliseconds when the request was made. + * This value is the very first section of the hash input. + * @property version the version of hashcash to use, only `1` is supported. + */ +public class Sha256MetaData + @JvmOverloads + public constructor( + public val world: Int, + public val difficulty: Int = 18, + public val epochTimeMillis: Long = System.currentTimeMillis(), + public val version: Int = 1, + ) : ChallengeMetaData { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Sha256MetaData) return false + + if (world != other.world) return false + if (difficulty != other.difficulty) return false + if (epochTimeMillis != other.epochTimeMillis) return false + if (version != other.version) return false + + return true + } + + override fun hashCode(): Int { + var result = world + result = 31 * result + difficulty + result = 31 * result + epochTimeMillis.hashCode() + result = 31 * result + version + return result + } + + override fun toString(): String = + "Sha256MetaData(" + + "world=$world, " + + "difficulty=$difficulty, " + + "epochTimeMillis=$epochTimeMillis, " + + "version=$version" + + ")" + } diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/DefaultSha256MessageDigestHashFunction.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/DefaultSha256MessageDigestHashFunction.kt new file mode 100644 index 000000000..6f7862b9f --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/DefaultSha256MessageDigestHashFunction.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.hashing + +import java.security.MessageDigest + +/** + * The default SHA-256 hash function using the [MessageDigest] implementation. + * Each hash request will generate a new instance of the [MessageDigest] object, + * as these implementations are not thread safe. + * These [MessageDigest] instances however are relatively cheap to construct. + */ +public data object DefaultSha256MessageDigestHashFunction : Sha256HashFunction { + override fun hash(input: ByteArray): ByteArray { + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(input) + return messageDigest.digest() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/Sha256HashFunction.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/Sha256HashFunction.kt new file mode 100644 index 000000000..3bd9bf6fe --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/Sha256HashFunction.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.hashing + +/** + * A SHA-256 hash function is a function used to turn the input bytes into a valid SHA-256 hash. + */ +public interface Sha256HashFunction { + /** + * The hash function takes an [input] byte array and turns it into a valid SHA-256 hash. + * @param input the input byte array to be hashed. + * @return the SHA-256 hash. + */ + public fun hash(input: ByteArray): ByteArray +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/ThreadLocalSha256MessageDigestHashFunction.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/ThreadLocalSha256MessageDigestHashFunction.kt new file mode 100644 index 000000000..b8b5d01ee --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/pow/challenges/sha256/hashing/ThreadLocalSha256MessageDigestHashFunction.kt @@ -0,0 +1,24 @@ +package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256.hashing + +import java.security.MessageDigest + +/** + * A SHA-256 hash function using the [MessageDigest] implementation. + * Unlike the default implementation, this one will utilize a thread-local implementation + * of the [MessageDigest] instances, which are all reset before use. + * + * @property digesters the thread-local message digest instances of SHA-256. + */ +public data object ThreadLocalSha256MessageDigestHashFunction : Sha256HashFunction { + private val digesters = + ThreadLocal.withInitial { + MessageDigest.getInstance("SHA-256") + } + + override fun hash(input: ByteArray): ByteArray { + val messageDigest = digesters.get() + messageDigest.reset() + messageDigest.update(input) + return messageDigest.digest() + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/AuthenticationType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/AuthenticationType.kt new file mode 100644 index 000000000..244b6ca7c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/AuthenticationType.kt @@ -0,0 +1,32 @@ +package net.rsprot.protocol.loginprot.incoming.util + +public sealed interface AuthenticationType { + public val otpAuthentication: OtpAuthenticationType + + /** + * Clears all data stored in this [AuthenticationType]. + * OTP codes will all be set to [Int.MIN_VALUE], + * password and token fields will be filled with zeros. + */ + public fun clear() + + public data class PasswordAuthentication( + public val password: Password, + override val otpAuthentication: OtpAuthenticationType, + ) : AuthenticationType { + override fun clear() { + otpAuthentication.clear() + password.clear() + } + } + + public data class TokenAuthentication( + public val token: Token, + override val otpAuthentication: OtpAuthenticationType, + ) : AuthenticationType { + override fun clear() { + otpAuthentication.clear() + token.clear() + } + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/CyclicRedundancyCheckBlock.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/CyclicRedundancyCheckBlock.kt new file mode 100644 index 000000000..a8890dcc9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/CyclicRedundancyCheckBlock.kt @@ -0,0 +1,36 @@ +package net.rsprot.protocol.loginprot.incoming.util + +/** + * CRC blocks are helper structures used for the server to verify that the CRC is up-to-date. + * As the client transmits less CRCs than there are cache indices, we provide validation methods + * through this abstract class at the respective revision's decoder level, so we can perform checks + * that correspond to the information received from the client, and not what the server fully knows of. + * @property clientCrc the int array of client CRCs, indexed by the cache archives. + */ +public abstract class CyclicRedundancyCheckBlock( + protected val clientCrc: IntArray, +) { + public abstract fun validate(serverCrc: IntArray): Boolean + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CyclicRedundancyCheckBlock) return false + + if (!clientCrc.contentEquals(other.clientCrc)) return false + + return true + } + + internal fun set( + index: Int, + value: Int, + ) { + this.clientCrc[index] = value + } + + public fun toIntArray(): IntArray = clientCrc.copyOf() + + override fun hashCode(): Int = clientCrc.contentHashCode() + + override fun toString(): String = "CyclicRedundancyCheckBlock(clientCrc=${clientCrc.contentToString()})" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/HostPlatformStats.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/HostPlatformStats.kt new file mode 100644 index 000000000..b7bd63872 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/HostPlatformStats.kt @@ -0,0 +1,160 @@ +package net.rsprot.protocol.loginprot.incoming.util + +@Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate") +public class HostPlatformStats( + private val _version: UByte, + private val _osType: UByte, + public val os64Bit: Boolean, + private val _osVersion: UShort, + private val _javaVendor: UByte, + private val _javaVersionMajor: UByte, + private val _javaVersionMinor: UByte, + private val _javaVersionPatch: UByte, + public val applet: Boolean, + private val _javaMaxMemoryMb: UShort, + private val _javaAvailableProcessors: UByte, + public val systemMemory: Int, + private val _systemSpeed: UShort, + public val gpuDxName: String, + public val gpuGlName: String, + public val gpuDxVersion: String, + public val gpuGlVersion: String, + private val _gpuDriverMonth: UByte, + private val _gpuDriverYear: UShort, + public val cpuManufacturer: String, + public val cpuBrand: String, + private val _cpuCount1: UByte, + private val _cpuCount2: UByte, + public val cpuFeatures: IntArray, + public val cpuSignature: Int, + public val clientName: String, + public val deviceName: String, +) { + public val version: Int + get() = _version.toInt() + public val osType: Int + get() = _osType.toInt() + public val osVersion: Int + get() = _osVersion.toInt() + public val javaVendor: Int + get() = _javaVendor.toInt() + public val javaVersionMajor: Int + get() = _javaVersionMajor.toInt() + public val javaVersionMinor: Int + get() = _javaVersionMinor.toInt() + public val javaVersionPatch: Int + get() = _javaVersionPatch.toInt() + public val javaMaxMemoryMb: Int + get() = _javaMaxMemoryMb.toInt() + public val javaAvailableProcessors: Int + get() = _javaAvailableProcessors.toInt() + public val systemSpeed: Int + get() = _systemSpeed.toInt() + public val gpuDriverMonth: Int + get() = _gpuDriverMonth.toInt() + public val gpuDriverYear: Int + get() = _gpuDriverYear.toInt() + public val cpuCount1: Int + get() = _cpuCount1.toInt() + public val cpuCount2: Int + get() = _cpuCount2.toInt() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HostPlatformStats + + if (_version != other._version) return false + if (_osType != other._osType) return false + if (os64Bit != other.os64Bit) return false + if (_osVersion != other._osVersion) return false + if (_javaVendor != other._javaVendor) return false + if (_javaVersionMajor != other._javaVersionMajor) return false + if (_javaVersionMinor != other._javaVersionMinor) return false + if (_javaVersionPatch != other._javaVersionPatch) return false + if (applet != other.applet) return false + if (_javaMaxMemoryMb != other._javaMaxMemoryMb) return false + if (_javaAvailableProcessors != other._javaAvailableProcessors) return false + if (systemMemory != other.systemMemory) return false + if (_systemSpeed != other._systemSpeed) return false + if (gpuDxName != other.gpuDxName) return false + if (gpuGlName != other.gpuGlName) return false + if (gpuDxVersion != other.gpuDxVersion) return false + if (gpuGlVersion != other.gpuGlVersion) return false + if (_gpuDriverMonth != other._gpuDriverMonth) return false + if (_gpuDriverYear != other._gpuDriverYear) return false + if (cpuManufacturer != other.cpuManufacturer) return false + if (cpuBrand != other.cpuBrand) return false + if (_cpuCount1 != other._cpuCount1) return false + if (_cpuCount2 != other._cpuCount2) return false + if (!cpuFeatures.contentEquals(other.cpuFeatures)) return false + if (cpuSignature != other.cpuSignature) return false + if (clientName != other.clientName) return false + if (deviceName != other.deviceName) return false + + return true + } + + override fun hashCode(): Int { + var result = _version.hashCode() + result = 31 * result + _osType.hashCode() + result = 31 * result + os64Bit.hashCode() + result = 31 * result + _osVersion.hashCode() + result = 31 * result + _javaVendor.hashCode() + result = 31 * result + _javaVersionMajor.hashCode() + result = 31 * result + _javaVersionMinor.hashCode() + result = 31 * result + _javaVersionPatch.hashCode() + result = 31 * result + applet.hashCode() + result = 31 * result + _javaMaxMemoryMb.hashCode() + result = 31 * result + _javaAvailableProcessors.hashCode() + result = 31 * result + systemMemory + result = 31 * result + _systemSpeed.hashCode() + result = 31 * result + gpuDxName.hashCode() + result = 31 * result + gpuGlName.hashCode() + result = 31 * result + gpuDxVersion.hashCode() + result = 31 * result + gpuGlVersion.hashCode() + result = 31 * result + _gpuDriverMonth.hashCode() + result = 31 * result + _gpuDriverYear.hashCode() + result = 31 * result + cpuManufacturer.hashCode() + result = 31 * result + cpuBrand.hashCode() + result = 31 * result + _cpuCount1.hashCode() + result = 31 * result + _cpuCount2.hashCode() + result = 31 * result + cpuFeatures.contentHashCode() + result = 31 * result + cpuSignature + result = 31 * result + clientName.hashCode() + result = 31 * result + deviceName.hashCode() + return result + } + + override fun toString(): String = + "HostPlatformStats(" + + "os64Bit=$os64Bit, " + + "systemMemory=$systemMemory, " + + "gpuDxName='$gpuDxName', " + + "gpuGlName='$gpuGlName', " + + "gpuDxVersion='$gpuDxVersion', " + + "gpuGlVersion='$gpuGlVersion', " + + "cpuManufacturer='$cpuManufacturer', " + + "cpuBrand='$cpuBrand', " + + "cpuFeatures=${cpuFeatures.contentToString()}, " + + "cpuSignature=$cpuSignature, " + + "clientName='$clientName', " + + "deviceName='$deviceName', " + + "version=$version, " + + "osType=$osType, " + + "osVersion=$osVersion, " + + "javaVendor=$javaVendor, " + + "javaVersionMajor=$javaVersionMajor, " + + "javaVersionMinor=$javaVersionMinor, " + + "javaVersionPatch=$javaVersionPatch, " + + "applet=$applet, " + + "javaMaxMemoryMb=$javaMaxMemoryMb, " + + "javaAvailableProcessors=$javaAvailableProcessors, " + + "systemSpeed=$systemSpeed, " + + "gpuDriverMonth=$gpuDriverMonth, " + + "gpuDriverYear=$gpuDriverYear, " + + "cpuCount1=$cpuCount1, " + + "cpuCount2=$cpuCount2" + + ")" +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlock.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlock.kt new file mode 100644 index 000000000..9f1506a12 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlock.kt @@ -0,0 +1,172 @@ +package net.rsprot.protocol.loginprot.incoming.util + +import net.rsprot.protocol.loginprot.incoming.RemainingBetaArchives + +@Suppress("MemberVisibilityCanBePrivate", "DuplicatedCode") +public class LoginBlock( + public val header: Header, + public val seed: IntArray, + public val sessionId: Long, + public val username: String, + public val lowDetail: Boolean, + public val resizable: Boolean, + private val _width: UShort, + private val _height: UShort, + public val uuid: ByteArray, + public val siteSettings: String, + public val affiliate: Int, + public val deepLinks: List, + public val hostPlatformStats: HostPlatformStats, + private val _validationClientType: UByte, + public val reflectionCheckerConst: Int, + public val crc: CyclicRedundancyCheckBlock, + public val authentication: T, +) { + // Property delegates for backwards compatibility + public val version: Int + get() = header.version + public val subVersion: Int + get() = header.subVersion + public val serverVersion: Int + get() = header.serverVersion + public val clientType: LoginClientType + get() = header.clientType + public val platformType: LoginPlatformType + get() = header.platformType + public val hasExternalAuthenticator: Boolean + get() = header.hasExternalAuthenticator + + public val width: Int + get() = _width.toInt() + public val height: Int + get() = _height.toInt() + public val validationClientType: LoginClientType + get() = LoginClientType[_validationClientType.toInt()] + + public fun mergeBetaCrcs(remainingBetaArchives: RemainingBetaArchives) { + for (i in RemainingBetaArchives.protectedArchives) { + this.crc.set(i, remainingBetaArchives.crc[i]) + } + } + + public class Header( + public val version: Int, + public val subVersion: Int, + public val serverVersion: Int, + private val _clientType: UByte, + private val _platformType: UByte, + public val hasExternalAuthenticator: Boolean, + ) { + public val clientType: LoginClientType + get() = LoginClientType[_clientType.toInt()] + public val platformType: LoginPlatformType + get() = LoginPlatformType[_platformType.toInt()] + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Header + + if (version != other.version) return false + if (subVersion != other.subVersion) return false + if (serverVersion != other.serverVersion) return false + if (hasExternalAuthenticator != other.hasExternalAuthenticator) return false + if (_clientType != other._clientType) return false + if (_platformType != other._platformType) return false + + return true + } + + override fun hashCode(): Int { + var result = version + result = 31 * result + subVersion + result = 31 * result + serverVersion + result = 31 * result + hasExternalAuthenticator.hashCode() + result = 31 * result + _clientType.hashCode() + result = 31 * result + _platformType.hashCode() + return result + } + + override fun toString(): String { + return "Header(" + + "version=$version, " + + "subVersion=$subVersion, " + + "serverVersion=$serverVersion, " + + "hasExternalAuthenticator=$hasExternalAuthenticator, " + + "clientType=$clientType, " + + "platformType=$platformType" + + ")" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LoginBlock<*> + + if (header != other.header) return false + if (!seed.contentEquals(other.seed)) return false + if (sessionId != other.sessionId) return false + if (username != other.username) return false + if (lowDetail != other.lowDetail) return false + if (resizable != other.resizable) return false + if (_width != other._width) return false + if (_height != other._height) return false + if (!uuid.contentEquals(other.uuid)) return false + if (siteSettings != other.siteSettings) return false + if (affiliate != other.affiliate) return false + if (deepLinks != other.deepLinks) return false + if (hostPlatformStats != other.hostPlatformStats) return false + if (_validationClientType != other._validationClientType) return false + if (reflectionCheckerConst != other.reflectionCheckerConst) return false + if (crc != other.crc) return false + if (authentication != other.authentication) return false + + return true + } + + override fun hashCode(): Int { + var result = header.hashCode() + result = 31 * result + seed.contentHashCode() + result = 31 * result + sessionId.hashCode() + result = 31 * result + username.hashCode() + result = 31 * result + lowDetail.hashCode() + result = 31 * result + resizable.hashCode() + result = 31 * result + _width.hashCode() + result = 31 * result + _height.hashCode() + result = 31 * result + uuid.contentHashCode() + result = 31 * result + siteSettings.hashCode() + result = 31 * result + affiliate + result = 31 * result + deepLinks.hashCode() + result = 31 * result + hostPlatformStats.hashCode() + result = 31 * result + _validationClientType.hashCode() + result = 31 * result + reflectionCheckerConst + result = 31 * result + crc.hashCode() + result = 31 * result + (authentication?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "LoginBlock(" + + "header=$header, " + + "seed=${seed.contentToString()}, " + + "sessionId=$sessionId, " + + "username='$username', " + + "lowDetail=$lowDetail, " + + "resizable=$resizable, " + + "uuid=${uuid.contentToString()}, " + + "siteSettings='$siteSettings', " + + "affiliate=$affiliate, " + + "hostPlatformStats=$hostPlatformStats, " + + "crc=$crc, " + + "deepLinks=$deepLinks, " + + "width=$width, " + + "height=$height, " + + "validationClientType=$validationClientType, " + + "reflectionCheckerConst=$reflectionCheckerConst, " + + "authentication=$authentication" + + ")" + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlockDecodingFunction.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlockDecodingFunction.kt new file mode 100644 index 000000000..63e880ad9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginBlockDecodingFunction.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.loginprot.incoming.util + +import net.rsprot.buffer.JagByteBuf + +public interface LoginBlockDecodingFunction { + public fun decode( + header: LoginBlock.Header, + buffer: JagByteBuf, + betaWorld: Boolean, + ): LoginBlock + + public fun decodeHeader(buffer: JagByteBuf): LoginBlock.Header +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginClientType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginClientType.kt new file mode 100644 index 000000000..86e7125a0 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginClientType.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.loginprot.incoming.util + +import net.rsprot.protocol.common.client.OldSchoolClientType + +public enum class LoginClientType( + public val id: Int, +) { + DESKTOP(1), + ANDROID(2), + IOS(3), + ENHANCED_WINDOWS(4), + ENHANCED_MAC(5), + ENHANCED_ANDROID(7), + ENHANCED_IOS(8), + ENHANCED_LINUX(10), + ; + + public fun toOldSchoolClientType(): OldSchoolClientType? { + return when (this) { + DESKTOP -> OldSchoolClientType.DESKTOP + ENHANCED_WINDOWS -> OldSchoolClientType.DESKTOP + ENHANCED_LINUX -> OldSchoolClientType.DESKTOP + ENHANCED_MAC -> OldSchoolClientType.DESKTOP + ENHANCED_ANDROID -> OldSchoolClientType.ANDROID + ENHANCED_IOS -> OldSchoolClientType.IOS + else -> null + } + } + + public companion object { + public operator fun get(id: Int): LoginClientType = + entries.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Unknown client type: $id") + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginPlatformType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginPlatformType.kt new file mode 100644 index 000000000..5afb8df78 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/LoginPlatformType.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.loginprot.incoming.util + +public enum class LoginPlatformType( + public val id: Int, +) { + DEFAULT(0), + STEAM(1), + ANDROID(2), + APPLE(3), + JAGEX(5), + ; + + public companion object { + public operator fun get(id: Int): LoginPlatformType = + entries.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Unknown platform type: $id") + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/OtpAuthenticationType.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/OtpAuthenticationType.kt new file mode 100644 index 000000000..120ea8d98 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/OtpAuthenticationType.kt @@ -0,0 +1,46 @@ +package net.rsprot.protocol.loginprot.incoming.util + +public sealed interface OtpAuthenticationType { + /** + * Clear any data stored in the one-time passwords by setting the + * payload to [Int.MIN_VALUE], if there is a payload attached. + */ + public fun clear() + + public data class TrustedComputer( + public var identifier: Int, + ) : OtpAuthenticationType { + override fun clear() { + identifier = Int.MIN_VALUE + } + } + + public data class TrustedAuthenticator( + override var otp: Int, + ) : OtpAuthentication { + override fun clear() { + otp = Int.MIN_VALUE + } + } + + public data object NoMultiFactorAuthentication : OtpAuthenticationType { + override fun clear() { + } + } + + public data class UntrustedAuthentication( + override var otp: Int, + ) : OtpAuthentication { + override fun clear() { + otp = Int.MIN_VALUE + } + } + + public sealed interface OtpAuthentication : OtpAuthenticationType { + /** + * One-time password, typically referred to as authentication code. + * This is the 6-digit 24-bit code used by two-factor authenticators. + */ + public var otp: Int + } +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/Password.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/Password.kt new file mode 100644 index 000000000..6a95e733c --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/incoming/util/Password.kt @@ -0,0 +1,57 @@ +package net.rsprot.protocol.loginprot.incoming.util + +import java.nio.ByteBuffer + +/** + * A class to hold the password. + * This class offers additional functionality to clear the data from memory, + * to avoid any potential memory attacks. + */ +public class Password( + public val data: ByteArray, +) { + /** + * Returns the string representation of the password. + * + * WARNING: As this constructs a string, it will be internalized and stored in memory. + * This means anyone capturing a heap dump is very likely to also capture some lingering + * passwords still in memory. + */ + @Suppress("MemberVisibilityCanBePrivate") + public fun asString(): String = String(data) + + /** + * Returns the char array representation of the password. + * This function does not create intermediate strings which would linger indefinitely + * in memory. The intermediate char buffer is also cleared after use. + * The [data] will not be automatically cleared. In order to do so, invoke [clear]. + */ + public fun asCharArray(): CharArray { + val byteBuffer = ByteBuffer.wrap(data) + val charBuffer = Charsets.UTF_8.decode(byteBuffer) + return try { + CharArray(charBuffer.remaining()).apply { charBuffer.get(this) } + } finally { + // Rewind the buffer to the very start after reading + charBuffer.rewind() + + // Manually overwrite all the values with the 0-byte character + val position = charBuffer.position() + val limit = charBuffer.limit() + for (i in position.., + ) : LoginResponse { + override fun estimateSize(): Int { + return proofOfWork + .challengeType + .estimateMessageSize() + } + } + + public data object DobError : LoginResponse + + public data object DobReview : LoginResponse + + public data object ClosedBeta : LoginResponse +} diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/outgoing/util/AuthenticatorResponse.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/outgoing/util/AuthenticatorResponse.kt new file mode 100644 index 000000000..545d827b2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/loginprot/outgoing/util/AuthenticatorResponse.kt @@ -0,0 +1,9 @@ +package net.rsprot.protocol.loginprot.outgoing.util + +public sealed interface AuthenticatorResponse { + public data object NoAuthenticator : AuthenticatorResponse + + public data class AuthenticatorCode( + public val code: Int, + ) : AuthenticatorResponse +} diff --git a/protocol/osrs-236/osrs-236-shared/build.gradle.kts b/protocol/osrs-236/osrs-236-shared/build.gradle.kts new file mode 100644 index 000000000..993c747b5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + api(platform(rootProject.libs.netty.bom)) + api(rootProject.libs.netty.buffer) + implementation(rootProject.libs.inline.logger) + api(rootProject.libs.netty.transport) + api(projects.buffer) + api(projects.compression) + api(projects.crypto) + api(projects.protocol) + api(projects.protocol.osrs236.osrs236Model) + api(projects.protocol.osrs236.osrs236Internal) + api(projects.protocol.osrs236.osrs236Common) +} + +mavenPublishing { + pom { + name = "RsProt OSRS 236 Shared" + description = "The shared module for revision 236 OldSchool RuneScape networking, " + + "offering a set of shared classes that do not depend on a specific client." + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/RSProtConstants.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/RSProtConstants.kt new file mode 100644 index 000000000..b29ffa057 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/RSProtConstants.kt @@ -0,0 +1,5 @@ +package net.rsprot.protocol.common + +public data object RSProtConstants { + public const val REVISION: Int = 236 +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PrefetchRequestDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PrefetchRequestDecoder.kt new file mode 100644 index 000000000..5fd7166a6 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PrefetchRequestDecoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.common.js5.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.js5.incoming.PrefetchRequest +import net.rsprot.protocol.message.codec.MessageDecoder + +public class PrefetchRequestDecoder : MessageDecoder { + override val prot: ClientProt = Js5ClientProt.PREFETCH_REQUEST + + override fun decode(buffer: JagByteBuf): PrefetchRequest { + val archiveId = buffer.g1() + val groupId = buffer.g2() + return PrefetchRequest( + archiveId.toUByte(), + groupId.toUShort(), + ) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeHighDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeHighDecoder.kt new file mode 100644 index 000000000..930278700 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeHighDecoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.common.js5.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.js5.incoming.PriorityChangeHigh +import net.rsprot.protocol.message.codec.MessageDecoder + +public class PriorityChangeHighDecoder : MessageDecoder { + override val prot: ClientProt = Js5ClientProt.PRIORITY_CHANGE_HIGH + + override fun decode(buffer: JagByteBuf): PriorityChangeHigh { + buffer.skipRead(3) + return PriorityChangeHigh + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeLowDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeLowDecoder.kt new file mode 100644 index 000000000..44c6132f9 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/PriorityChangeLowDecoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.common.js5.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.js5.incoming.PriorityChangeLow +import net.rsprot.protocol.message.codec.MessageDecoder + +public class PriorityChangeLowDecoder : MessageDecoder { + override val prot: ClientProt = Js5ClientProt.PRIORITY_CHANGE_LOW + + override fun decode(buffer: JagByteBuf): PriorityChangeLow { + buffer.skipRead(3) + return PriorityChangeLow + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/UrgentRequestDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/UrgentRequestDecoder.kt new file mode 100644 index 000000000..b55e28045 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/UrgentRequestDecoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.common.js5.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.js5.incoming.UrgentRequest +import net.rsprot.protocol.message.codec.MessageDecoder + +public class UrgentRequestDecoder : MessageDecoder { + override val prot: ClientProt = Js5ClientProt.URGENT_REQUEST + + override fun decode(buffer: JagByteBuf): UrgentRequest { + val archiveId = buffer.g1() + val groupId = buffer.g2() + return UrgentRequest( + archiveId.toUByte(), + groupId.toUShort(), + ) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/XorChangeDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/XorChangeDecoder.kt new file mode 100644 index 000000000..a01e8de94 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/codec/XorChangeDecoder.kt @@ -0,0 +1,17 @@ +package net.rsprot.protocol.common.js5.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.js5.incoming.prot.Js5ClientProt +import net.rsprot.protocol.js5.incoming.XorChange +import net.rsprot.protocol.message.codec.MessageDecoder + +public class XorChangeDecoder : MessageDecoder { + override val prot: ClientProt = Js5ClientProt.XOR_CHANGE + + override fun decode(buffer: JagByteBuf): XorChange { + val key = buffer.g1() + buffer.skipRead(2) + return XorChange(key) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProt.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProt.kt new file mode 100644 index 000000000..a7bcfd2d2 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProt.kt @@ -0,0 +1,14 @@ +package net.rsprot.protocol.common.js5.incoming.prot + +import net.rsprot.protocol.ClientProt + +public enum class Js5ClientProt( + override val opcode: Int, + override val size: Int, +) : ClientProt { + PREFETCH_REQUEST(Js5ClientProtId.PREFETCH_REQUEST, 3), + URGENT_REQUEST(Js5ClientProtId.URGENT_REQUEST, 3), + PRIORITY_CHANGE_HIGH(Js5ClientProtId.PRIORITY_CHANGE_HIGH, 3), + PRIORITY_CHANGE_LOW(Js5ClientProtId.PRIORITY_CHANGE_LOW, 3), + XOR_CHANGE(Js5ClientProtId.XOR_CHANGE, 3), +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProtId.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProtId.kt new file mode 100644 index 000000000..b1e4ef29c --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5ClientProtId.kt @@ -0,0 +1,9 @@ +package net.rsprot.protocol.common.js5.incoming.prot + +internal object Js5ClientProtId { + const val PREFETCH_REQUEST = 0 + const val URGENT_REQUEST = 1 + const val PRIORITY_CHANGE_HIGH = 2 + const val PRIORITY_CHANGE_LOW = 3 + const val XOR_CHANGE = 4 +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5MessageDecoderRepository.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5MessageDecoderRepository.kt new file mode 100644 index 000000000..9c2ea833d --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/incoming/prot/Js5MessageDecoderRepository.kt @@ -0,0 +1,26 @@ +package net.rsprot.protocol.common.js5.incoming.prot + +import net.rsprot.protocol.ProtRepository +import net.rsprot.protocol.common.js5.incoming.codec.PrefetchRequestDecoder +import net.rsprot.protocol.common.js5.incoming.codec.PriorityChangeHighDecoder +import net.rsprot.protocol.common.js5.incoming.codec.PriorityChangeLowDecoder +import net.rsprot.protocol.common.js5.incoming.codec.UrgentRequestDecoder +import net.rsprot.protocol.common.js5.incoming.codec.XorChangeDecoder +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepository +import net.rsprot.protocol.message.codec.incoming.MessageDecoderRepositoryBuilder + +public object Js5MessageDecoderRepository { + @ExperimentalStdlibApi + public fun build(): MessageDecoderRepository { + val protRepository = ProtRepository.of() + val builder = + MessageDecoderRepositoryBuilder(protRepository).apply { + bind(PrefetchRequestDecoder()) + bind(PriorityChangeHighDecoder()) + bind(PriorityChangeLowDecoder()) + bind(UrgentRequestDecoder()) + bind(XorChangeDecoder()) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/codec/Js5GroupResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/codec/Js5GroupResponseEncoder.kt new file mode 100644 index 000000000..b39400f65 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/codec/Js5GroupResponseEncoder.kt @@ -0,0 +1,52 @@ +package net.rsprot.protocol.common.js5.outgoing.codec + +import com.github.michaelbull.logging.InlineLogger +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.js5.outgoing.prot.Js5ServerProt +import net.rsprot.protocol.js5.outgoing.Js5GroupResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class Js5GroupResponseEncoder : MessageEncoder { + override val prot: ServerProt = Js5ServerProt.JS5_GROUP_RESPONSE + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: Js5GroupResponse, + ) { + val messageBuf = message.content() + // Perform a quick one-time validation to ensure the server is yielding the same + // type bytebuffers that the Netty pipeline is expecting, to avoid very expensive + // copying (either from heap to native or native to heap memory) + if (!validatedBufferCompatibility) { + validatedBufferCompatibility = true + val isPipelineDirect = buffer.buffer.isDirect + val isMessageDirect = messageBuf.isDirect + if (isPipelineDirect != isMessageDirect) { + logger.warn { + "Incompatible JS5 buffer types; " + + "pipeline is direct: $isPipelineDirect, message is direct: $isMessageDirect; " + + "Using incompatible types means there is a more expensive copying occurring each " + + "time a buffer is written out." + } + } else { + logger.debug { "Using compatible JS5 buffer types (direct: $isPipelineDirect)" } + } + } + // Only write the bytes to the `out` variable if XOR is used + if (message.key != 0) { + val out = buffer.buffer + for (i in 0.. { + val protRepository = ProtRepository.of() + val builder = + MessageEncoderRepositoryBuilder( + protRepository, + ).apply { + bind(Js5GroupResponseEncoder()) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/prot/Js5ServerProt.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/prot/Js5ServerProt.kt new file mode 100644 index 000000000..80a798752 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/js5/outgoing/prot/Js5ServerProt.kt @@ -0,0 +1,12 @@ +package net.rsprot.protocol.common.js5.outgoing.prot + +import net.rsprot.protocol.ServerProt + +public enum class Js5ServerProt( + override val opcode: Int, + override val size: Int, +) : ServerProt { + // Js5 responses have no actual opcode, + // but we do need to have something to identify it by within the lib + JS5_GROUP_RESPONSE(0, -2), +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameLoginDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameLoginDecoder.kt new file mode 100644 index 000000000..853795219 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameLoginDecoder.kt @@ -0,0 +1,102 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.loginprot.incoming.codec.shared.LoginBlockDecoder +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.GameLogin +import net.rsprot.protocol.loginprot.incoming.LoginDecodingFunction +import net.rsprot.protocol.loginprot.incoming.util.AuthenticationType +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.OtpAuthenticationType +import net.rsprot.protocol.loginprot.incoming.util.Password +import net.rsprot.protocol.loginprot.incoming.util.Token +import net.rsprot.protocol.message.codec.MessageDecoder +import java.math.BigInteger + +public class GameLoginDecoder( + private val supportedClientTypes: List, + exp: BigInteger, + mod: BigInteger, +) : LoginBlockDecoder(exp, mod), + MessageDecoder { + override val prot: ClientProt = LoginClientProt.GAMELOGIN + + private val decoder = + object : LoginDecodingFunction { + override fun decode( + header: LoginBlock.Header, + buffer: JagByteBuf, + betaWorld: Boolean, + ): LoginBlock { + return decodeLoginBlock(header, buffer, betaWorld) + } + + override fun decodeHeader(buffer: JagByteBuf): LoginBlock.Header { + return decodeHeader(buffer, supportedClientTypes) + } + } + + override fun decode(buffer: JagByteBuf): GameLogin { + val copy = buffer.buffer.copy() + // Mark the buffer as "read" as copy function doesn't do it automatically. + buffer.buffer.readerIndex(buffer.buffer.writerIndex()) + return GameLogin(copy.toJagByteBuf(), decoder) + } + + override fun decodeAuthentication(buffer: JagByteBuf): AuthenticationType { + val otp = decodeOtpAuthentication(buffer) + return when (val authenticationType = buffer.g1()) { + PASSWORD_AUTHENTICATION -> + AuthenticationType.PasswordAuthentication( + Password(buffer.gjstr().toByteArray()), + otp, + ) + TOKEN_AUTHENTICATION -> + AuthenticationType.TokenAuthentication( + Token(buffer.gjstr().toByteArray()), + otp, + ) + else -> { + throw IllegalStateException("Unknown authentication type: $authenticationType") + } + } + } + + private fun decodeOtpAuthentication(buffer: JagByteBuf): OtpAuthenticationType = + when (val otpType = buffer.g1()) { + OTP_TOKEN -> { + val identifier = buffer.g4() + OtpAuthenticationType.TrustedComputer(identifier) + } + OTP_REMEMBER -> { + val otpKey = buffer.g3() + buffer.skipRead(1) + OtpAuthenticationType.TrustedAuthenticator(otpKey) + } + OTP_NONE -> { + buffer.skipRead(4) + OtpAuthenticationType.NoMultiFactorAuthentication + } + OTP_FORGET -> { + val otpKey = buffer.g3() + buffer.skipRead(1) + OtpAuthenticationType.UntrustedAuthentication(otpKey) + } + else -> { + throw IllegalStateException("Unknown authentication type: $otpType") + } + } + + private companion object { + private const val OTP_TOKEN = 0 + private const val OTP_REMEMBER = 1 + private const val OTP_NONE = 2 + private const val OTP_FORGET = 3 + + private const val PASSWORD_AUTHENTICATION = 0 + private const val TOKEN_AUTHENTICATION = 2 + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameReconnectDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameReconnectDecoder.kt new file mode 100644 index 000000000..f72330562 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/GameReconnectDecoder.kt @@ -0,0 +1,52 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.xtea.XteaKey +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.loginprot.incoming.codec.shared.LoginBlockDecoder +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.GameReconnect +import net.rsprot.protocol.loginprot.incoming.ReconnectDecodingFunction +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.message.codec.MessageDecoder +import java.math.BigInteger + +public class GameReconnectDecoder( + private val supportedClientTypes: List, + exp: BigInteger, + mod: BigInteger, +) : LoginBlockDecoder(exp, mod), + MessageDecoder { + override val prot: ClientProt = LoginClientProt.GAMERECONNECT + + private val decoder = + object : ReconnectDecodingFunction { + override fun decode( + header: LoginBlock.Header, + buffer: JagByteBuf, + betaWorld: Boolean, + ): LoginBlock { + return decodeLoginBlock(header, buffer, betaWorld) + } + + override fun decodeHeader(buffer: JagByteBuf): LoginBlock.Header { + return decodeHeader(buffer, supportedClientTypes) + } + } + + override fun decode(buffer: JagByteBuf): GameReconnect { + val copy = buffer.buffer.copy() + // Mark the buffer as "read" as copy function doesn't do it automatically. + buffer.buffer.readerIndex(buffer.buffer.writerIndex()) + return GameReconnect(copy.toJagByteBuf(), decoder) + } + + override fun decodeAuthentication(buffer: JagByteBuf): XteaKey = + XteaKey( + IntArray(4) { + buffer.g4() + }, + ) +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitGameConnectionDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitGameConnectionDecoder.kt new file mode 100644 index 000000000..5079cd25c --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitGameConnectionDecoder.kt @@ -0,0 +1,13 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.InitGameConnection +import net.rsprot.protocol.message.codec.MessageDecoder + +public class InitGameConnectionDecoder : MessageDecoder { + override val prot: ClientProt = LoginClientProt.INIT_GAME_CONNECTION + + override fun decode(buffer: JagByteBuf): InitGameConnection = InitGameConnection +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitJs5RemoteConnectionDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitJs5RemoteConnectionDecoder.kt new file mode 100644 index 000000000..8a041466b --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/InitJs5RemoteConnectionDecoder.kt @@ -0,0 +1,20 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.InitJs5RemoteConnection +import net.rsprot.protocol.message.codec.MessageDecoder + +public class InitJs5RemoteConnectionDecoder : MessageDecoder { + override val prot: ClientProt = LoginClientProt.INIT_JS5REMOTE_CONNECTION + + override fun decode(buffer: JagByteBuf): InitJs5RemoteConnection { + val revision = buffer.g4() + val seed = + IntArray(4) { + buffer.g4() + } + return InitJs5RemoteConnection(revision, seed) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/ProofOfWorkReplyDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/ProofOfWorkReplyDecoder.kt new file mode 100644 index 000000000..71bc9b9f4 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/ProofOfWorkReplyDecoder.kt @@ -0,0 +1,16 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.ProofOfWorkReply +import net.rsprot.protocol.message.codec.MessageDecoder + +public class ProofOfWorkReplyDecoder : MessageDecoder { + override val prot: ClientProt = LoginClientProt.POW_REPLY + + override fun decode(buffer: JagByteBuf): ProofOfWorkReply { + val result = buffer.g8() + return ProofOfWorkReply(result) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/RemainingBetaArchivesDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/RemainingBetaArchivesDecoder.kt new file mode 100644 index 000000000..2d91cb5b1 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/RemainingBetaArchivesDecoder.kt @@ -0,0 +1,35 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.protocol.ClientProt +import net.rsprot.protocol.common.loginprot.incoming.prot.LoginClientProt +import net.rsprot.protocol.loginprot.incoming.RemainingBetaArchives +import net.rsprot.protocol.message.codec.MessageDecoder + +public class RemainingBetaArchivesDecoder : MessageDecoder { + override val prot: ClientProt = LoginClientProt.REMAINING_BETA_ARCHIVE_CRCS + + override fun decode(buffer: JagByteBuf): RemainingBetaArchives { + check(buffer.g2() == 66) { + "Expected remaining beta archives size of 66" + } + val crc = IntArray(23) + crc[20] = buffer.g4Alt1() + crc[3] = buffer.g4Alt2() + crc[1] = buffer.g4Alt1() + crc[5] = buffer.g4Alt1() + crc[18] = buffer.g4() + crc[12] = buffer.g4Alt1() + crc[22] = buffer.g4Alt3() + crc[2] = buffer.g4() + crc[7] = buffer.g4() + crc[19] = buffer.g4Alt2() + crc[16] = buffer.g4Alt2() + crc[9] = buffer.g4Alt1() + crc[0] = buffer.g4() + crc[17] = buffer.g4Alt1() + crc[21] = buffer.g4() + crc[11] = buffer.g4() + return RemainingBetaArchives(crc) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/shared/LoginBlockDecoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/shared/LoginBlockDecoder.kt new file mode 100644 index 000000000..fa854d179 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/incoming/codec/shared/LoginBlockDecoder.kt @@ -0,0 +1,276 @@ +package net.rsprot.protocol.common.loginprot.incoming.codec.shared + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.buffer.extensions.toJagByteBuf +import net.rsprot.crypto.rsa.decipherRsa +import net.rsprot.crypto.xtea.xteaDecrypt +import net.rsprot.protocol.common.RSProtConstants +import net.rsprot.protocol.common.client.OldSchoolClientType +import net.rsprot.protocol.common.loginprot.incoming.codec.shared.exceptions.InvalidVersionException +import net.rsprot.protocol.common.loginprot.incoming.codec.shared.exceptions.UnsupportedClientException +import net.rsprot.protocol.loginprot.incoming.util.CyclicRedundancyCheckBlock +import net.rsprot.protocol.loginprot.incoming.util.HostPlatformStats +import net.rsprot.protocol.loginprot.incoming.util.LoginBlock +import net.rsprot.protocol.loginprot.incoming.util.LoginClientType +import java.math.BigInteger + +@Suppress("DuplicatedCode") +public abstract class LoginBlockDecoder( + private val exp: BigInteger, + private val mod: BigInteger, +) { + protected abstract fun decodeAuthentication(buffer: JagByteBuf): T + + public fun decodeHeader( + buffer: JagByteBuf, + supportedClientTypes: List, + ): LoginBlock.Header { + val version = buffer.g4() + if (version != RSProtConstants.REVISION) { + throw InvalidVersionException + } + val subVersion = buffer.g4() + val serverVersion = buffer.g4() + val firstClientType = buffer.g1() + val loginClientType = LoginClientType[firstClientType] + val oldSchoolClientType = loginClientType.toOldSchoolClientType() + if (oldSchoolClientType !in supportedClientTypes) { + throw UnsupportedClientException + } + val platformType = buffer.g1() + val hasExternalAuthenticator = buffer.g1() == 1 + return LoginBlock.Header( + version, + subVersion, + serverVersion, + firstClientType.toUByte(), + platformType.toUByte(), + hasExternalAuthenticator, + ) + } + + protected fun decodeLoginBlock( + header: LoginBlock.Header, + buffer: JagByteBuf, + betaWorld: Boolean, + ): LoginBlock { + try { + val rsaSize = buffer.g2() + if (!buffer.isReadable(rsaSize)) { + throw IllegalStateException("RSA buffer not readable: $rsaSize, ${buffer.readableBytes()}") + } + val rsaBuffer = + buffer.buffer + .decipherRsa( + exp, + mod, + rsaSize, + ).toJagByteBuf() + try { + val encryptionCheck = rsaBuffer.g1() + check(encryptionCheck == 1) { + "Invalid RSA check '$encryptionCheck'. " + + "This typically means the RSA in the client does not match up with the server." + } + val seed = + IntArray(4) { + rsaBuffer.g4() + } + val sessionId = rsaBuffer.g8() + val authentication = decodeAuthentication(rsaBuffer) + val xteaBuffer = buffer.buffer.xteaDecrypt(seed).toJagByteBuf() + try { + val username = xteaBuffer.gjstr() + val packedClientSettings = xteaBuffer.g1() + val lowDetail = packedClientSettings and 0x1 != 0 + val resizable = packedClientSettings and 0x2 != 0 + val width = xteaBuffer.g2() + val height = xteaBuffer.g2() + val uuid = + ByteArray(24) { + xteaBuffer.g1().toByte() + } + val siteSettings = xteaBuffer.gjstr() + val affiliate = xteaBuffer.g4() + val deepLinkCount = xteaBuffer.g1() + val deepLinks = + if (deepLinkCount == 0) { + emptyList() + } else { + List(deepLinkCount) { + xteaBuffer.g4() + } + } + val hostPlatformStats = decodeHostPlatformStats(xteaBuffer) + val secondClientType = xteaBuffer.g1() + if (secondClientType != header.clientType.id) { + throw UnsupportedClientException + } + val reflectionCheckerConst = xteaBuffer.g4() + val crc = + if (betaWorld) { + decodeBetaCrc(xteaBuffer) + } else { + decodeCrc(xteaBuffer) + } + return LoginBlock( + header, + seed, + sessionId, + username, + lowDetail, + resizable, + width.toUShort(), + height.toUShort(), + uuid, + siteSettings, + affiliate, + deepLinks, + hostPlatformStats, + secondClientType.toUByte(), + reflectionCheckerConst, + crc, + authentication, + ) + } finally { + xteaBuffer.buffer.release() + } + } finally { + rsaBuffer.buffer.release() + } + } finally { + buffer.buffer.release() + } + } + + private fun decodeCrc(buffer: JagByteBuf): CyclicRedundancyCheckBlock { + val crc = IntArray(TRANSMITTED_CRC_COUNT) + crc[20] = buffer.g4Alt3() + crc[0] = buffer.g4Alt1() + crc[22] = buffer.g4() + crc[1] = buffer.g4() + crc[15] = buffer.g4Alt2() + crc[11] = buffer.g4Alt1() + crc[5] = buffer.g4Alt1() + crc[13] = buffer.g4() + crc[16] = buffer.g4Alt1() + crc[17] = buffer.g4Alt1() + crc[7] = buffer.g4() + crc[4] = buffer.g4() + crc[18] = buffer.g4Alt3() + crc[10] = buffer.g4() + crc[14] = buffer.g4() + crc[3] = buffer.g4Alt3() + crc[2] = buffer.g4Alt2() + crc[21] = buffer.g4() + crc[12] = buffer.g4() + crc[19] = buffer.g4Alt1() + crc[8] = buffer.g4Alt2() + crc[9] = buffer.g4Alt3() + crc[6] = buffer.g4Alt2() + + return object : CyclicRedundancyCheckBlock(crc) { + override fun validate(serverCrc: IntArray): Boolean { + require(serverCrc.size >= TRANSMITTED_CRC_COUNT) { + "Server CRC length less than expected: ${serverCrc.size}, expected >= $TRANSMITTED_CRC_COUNT" + } + for (i in 0..= TRANSMITTED_CRC_COUNT) { + "Server CRC length less than expected: ${serverCrc.size}, expected >= $TRANSMITTED_CRC_COUNT" + } + for (i in 0.., + exp: BigInteger, + mod: BigInteger, + ): MessageDecoderRepository { + val protRepository = ProtRepository.of() + val builder = + MessageDecoderRepositoryBuilder( + protRepository, + ).apply { + bind(InitGameConnectionDecoder()) + bind(InitJs5RemoteConnectionDecoder()) + bind(GameLoginDecoder(supportedClientTypes, exp, mod)) + bind(GameReconnectDecoder(supportedClientTypes, exp, mod)) + bind(ProofOfWorkReplyDecoder()) + bind(RemainingBetaArchivesDecoder()) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/DisallowedByScriptLoginResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/DisallowedByScriptLoginResponseEncoder.kt new file mode 100644 index 000000000..782a7c917 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/DisallowedByScriptLoginResponseEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class DisallowedByScriptLoginResponseEncoder : MessageEncoder { + override val prot: ServerProt = LoginServerProt.DISALLOWED_BY_SCRIPT + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LoginResponse.DisallowedByScript, + ) { + buffer.pjstr(message.line1) + buffer.pjstr(message.line2) + buffer.pjstr(message.line3) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/EmptyLoginResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/EmptyLoginResponseEncoder.kt new file mode 100644 index 000000000..2eca36712 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/EmptyLoginResponseEncoder.kt @@ -0,0 +1,18 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.message.OutgoingMessage +import net.rsprot.protocol.message.codec.MessageEncoder + +public class EmptyLoginResponseEncoder( + override val prot: ServerProt, +) : MessageEncoder { + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: T, + ) { + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/OkLoginResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/OkLoginResponseEncoder.kt new file mode 100644 index 000000000..0adf7f420 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/OkLoginResponseEncoder.kt @@ -0,0 +1,41 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.loginprot.outgoing.util.AuthenticatorResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class OkLoginResponseEncoder : MessageEncoder { + override val prot: ServerProt = LoginServerProt.OK + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LoginResponse.Ok, + ) { + when (val response = message.authenticatorResponse) { + is AuthenticatorResponse.AuthenticatorCode -> { + val code = response.code + buffer.p1(1) + buffer.p1((code ushr 24 and 0xFF) + streamCipher.nextInt()) + buffer.p1((code ushr 16 and 0xFF) + streamCipher.nextInt()) + buffer.p1((code ushr 8 and 0xFF) + streamCipher.nextInt()) + buffer.p1((code and 0xFF) + streamCipher.nextInt()) + } + AuthenticatorResponse.NoAuthenticator -> { + buffer.p1(0) + buffer.p4(0) + } + } + buffer.p1(message.staffModLevel) + buffer.pboolean(message.playerMod) + buffer.p2(message.index) + buffer.pboolean(message.member) + buffer.p8(message.accountHash) + buffer.p8(message.userId) + buffer.p8(message.userHash) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ProofOfWorkResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ProofOfWorkResponseEncoder.kt new file mode 100644 index 000000000..1fe191684 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ProofOfWorkResponseEncoder.kt @@ -0,0 +1,22 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ProofOfWorkResponseEncoder : MessageEncoder { + override val prot: ServerProt = LoginServerProt.PROOF_OF_WORK + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LoginResponse.ProofOfWork, + ) { + val challenge = message.proofOfWork.challengeType + buffer.p1(message.proofOfWork.challengeType.id) + challenge.encode(buffer) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ReconnectOkResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ReconnectOkResponseEncoder.kt new file mode 100644 index 000000000..4936d0767 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/ReconnectOkResponseEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class ReconnectOkResponseEncoder : MessageEncoder { + override val prot: ServerProt = LoginServerProt.RECONNECT_OK + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LoginResponse.ReconnectOk, + ) { + // Due to message extending byte buf holder, it is automatically released by the pipeline + buffer.buffer.writeBytes(message.content()) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/SuccessfulLoginResponseEncoder.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/SuccessfulLoginResponseEncoder.kt new file mode 100644 index 000000000..2b18b6d43 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/codec/SuccessfulLoginResponseEncoder.kt @@ -0,0 +1,21 @@ +package net.rsprot.protocol.common.loginprot.outgoing.codec + +import net.rsprot.buffer.JagByteBuf +import net.rsprot.crypto.cipher.StreamCipher +import net.rsprot.protocol.ServerProt +import net.rsprot.protocol.common.loginprot.outgoing.prot.LoginServerProt +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.codec.MessageEncoder + +public class SuccessfulLoginResponseEncoder : MessageEncoder { + override val prot: ServerProt = LoginServerProt.SUCCESSFUL + + override fun encode( + streamCipher: StreamCipher, + buffer: JagByteBuf, + message: LoginResponse.Successful, + ) { + val sessionId = message.sessionId ?: return + buffer.p8(sessionId) + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginMessageEncoderRepository.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginMessageEncoderRepository.kt new file mode 100644 index 000000000..2ac7e801f --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginMessageEncoderRepository.kt @@ -0,0 +1,84 @@ +package net.rsprot.protocol.common.loginprot.outgoing.prot + +import net.rsprot.protocol.ProtRepository +import net.rsprot.protocol.common.loginprot.outgoing.codec.DisallowedByScriptLoginResponseEncoder +import net.rsprot.protocol.common.loginprot.outgoing.codec.EmptyLoginResponseEncoder +import net.rsprot.protocol.common.loginprot.outgoing.codec.OkLoginResponseEncoder +import net.rsprot.protocol.common.loginprot.outgoing.codec.ProofOfWorkResponseEncoder +import net.rsprot.protocol.common.loginprot.outgoing.codec.ReconnectOkResponseEncoder +import net.rsprot.protocol.common.loginprot.outgoing.codec.SuccessfulLoginResponseEncoder +import net.rsprot.protocol.loginprot.outgoing.LoginResponse +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepository +import net.rsprot.protocol.message.codec.outgoing.MessageEncoderRepositoryBuilder + +private typealias Encoder = EmptyLoginResponseEncoder + +public object LoginMessageEncoderRepository { + @ExperimentalStdlibApi + public fun build(): MessageEncoderRepository { + val protRepository = ProtRepository.of() + val builder = + MessageEncoderRepositoryBuilder( + protRepository, + ).apply { + bind(OkLoginResponseEncoder()) + bind(DisallowedByScriptLoginResponseEncoder()) + bind(ProofOfWorkResponseEncoder()) + bind(SuccessfulLoginResponseEncoder()) + bind(ReconnectOkResponseEncoder()) + bind(Encoder(LoginServerProt.INVALID_USERNAME_OR_PASSWORD)) + bind(Encoder(LoginServerProt.BANNED)) + bind(Encoder(LoginServerProt.DUPLICATE)) + bind(Encoder(LoginServerProt.CLIENT_OUT_OF_DATE)) + bind(Encoder(LoginServerProt.SERVER_FULL)) + bind(Encoder(LoginServerProt.LOGINSERVER_OFFLINE)) + bind(Encoder(LoginServerProt.IP_LIMIT)) + bind(Encoder(LoginServerProt.BAD_SESSION_ID)) + bind(Encoder(LoginServerProt.FORCE_PASSWORD_CHANGE)) + bind(Encoder(LoginServerProt.NEED_MEMBERS_ACCOUNT)) + bind(Encoder(LoginServerProt.INVALID_SAVE)) + bind(Encoder(LoginServerProt.UPDATE_IN_PROGRESS)) + bind(Encoder(LoginServerProt.TOO_MANY_ATTEMPTS)) + bind(Encoder(LoginServerProt.IN_MEMBERS_AREA)) + bind(Encoder(LoginServerProt.LOCKED)) + bind(Encoder(LoginServerProt.CLOSED_BETA_INVITED_ONLY)) + bind(Encoder(LoginServerProt.INVALID_LOGINSERVER)) + bind(Encoder(LoginServerProt.HOP_BLOCKED)) + bind(Encoder(LoginServerProt.INVALID_LOGIN_PACKET)) + bind(Encoder(LoginServerProt.LOGINSERVER_NO_REPLY)) + bind(Encoder(LoginServerProt.LOGINSERVER_LOAD_ERROR)) + bind(Encoder(LoginServerProt.UNKNOWN_REPLY_FROM_LOGINSERVER)) + bind(Encoder(LoginServerProt.IP_BLOCKED)) + bind(Encoder(LoginServerProt.SERVICE_UNAVAILABLE)) + bind(Encoder(LoginServerProt.DISPLAYNAME_REQUIRED)) + bind(Encoder(LoginServerProt.NEGATIVE_CREDIT)) + bind(Encoder(LoginServerProt.INVALID_SINGLE_SIGNON)) + bind(Encoder(LoginServerProt.NO_REPLY_FROM_SINGLE_SIGNON)) + bind(Encoder(LoginServerProt.PROFILE_BEING_EDITED)) + bind(Encoder(LoginServerProt.NO_BETA_ACCESS)) + bind(Encoder(LoginServerProt.INSTANCE_INVALID)) + bind(Encoder(LoginServerProt.INSTANCE_NOT_SPECIFIED)) + bind(Encoder(LoginServerProt.INSTANCE_FULL)) + bind(Encoder(LoginServerProt.IN_QUEUE)) + bind(Encoder(LoginServerProt.ALREADY_IN_QUEUE)) + bind(Encoder(LoginServerProt.BILLING_TIMEOUT)) + bind(Encoder(LoginServerProt.NOT_AGREED_TO_NDA)) + bind(Encoder(LoginServerProt.EMAIL_NOT_VALIDATED)) + bind(Encoder(LoginServerProt.CONNECT_FAIL)) + bind(Encoder(LoginServerProt.PRIVACY_POLICY)) + bind(Encoder(LoginServerProt.AUTHENTICATOR)) + bind(Encoder(LoginServerProt.INVALID_AUTHENTICATOR_CODE)) + bind(Encoder(LoginServerProt.UPDATE_DOB)) + bind(Encoder(LoginServerProt.TIMEOUT)) + bind(Encoder(LoginServerProt.KICK)) + bind(Encoder(LoginServerProt.RETRY)) + bind(Encoder(LoginServerProt.LOGIN_FAIL_1)) + bind(Encoder(LoginServerProt.LOGIN_FAIL_2)) + bind(Encoder(LoginServerProt.OUT_OF_DATE_RELOAD)) + bind(Encoder(LoginServerProt.DOB_ERROR)) + bind(Encoder(LoginServerProt.DOB_REVIEW)) + bind(Encoder(LoginServerProt.CLOSED_BETA)) + } + return builder.build() + } +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProt.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProt.kt new file mode 100644 index 000000000..da76ca1b5 --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProt.kt @@ -0,0 +1,68 @@ +package net.rsprot.protocol.common.loginprot.outgoing.prot + +import net.rsprot.protocol.Prot +import net.rsprot.protocol.ServerProt + +public enum class LoginServerProt( + override val opcode: Int, + override val size: Int, +) : ServerProt { + SUCCESSFUL(LoginServerProtId.SUCCESSFUL, 0), + OK(LoginServerProtId.OK, Prot.VAR_BYTE), + INVALID_USERNAME_OR_PASSWORD(LoginServerProtId.INVALID_USERNAME_OR_PASSWORD, 0), + BANNED(LoginServerProtId.BANNED, 0), + DUPLICATE(LoginServerProtId.DUPLICATE, 0), + CLIENT_OUT_OF_DATE(LoginServerProtId.CLIENT_OUT_OF_DATE, 0), + SERVER_FULL(LoginServerProtId.SERVER_FULL, 0), + LOGINSERVER_OFFLINE(LoginServerProtId.LOGINSERVER_OFFLINE, 0), + IP_LIMIT(LoginServerProtId.IP_LIMIT, 0), + BAD_SESSION_ID(LoginServerProtId.BAD_SESSION_ID, 0), + FORCE_PASSWORD_CHANGE(LoginServerProtId.FORCE_PASSWORD_CHANGE, 0), + NEED_MEMBERS_ACCOUNT(LoginServerProtId.NEED_MEMBERS_ACCOUNT, 0), + INVALID_SAVE(LoginServerProtId.INVALID_SAVE, 0), + UPDATE_IN_PROGRESS(LoginServerProtId.UPDATE_IN_PROGRESS, 0), + RECONNECT_OK(LoginServerProtId.RECONNECT_OK, Prot.VAR_SHORT), + TOO_MANY_ATTEMPTS(LoginServerProtId.TOO_MANY_ATTEMPTS, 0), + IN_MEMBERS_AREA(LoginServerProtId.IN_MEMBERS_AREA, 0), + LOCKED(LoginServerProtId.LOCKED, 0), + CLOSED_BETA_INVITED_ONLY(LoginServerProtId.CLOSED_BETA_INVITED_ONLY, 0), + INVALID_LOGINSERVER(LoginServerProtId.INVALID_LOGINSERVER, 0), + HOP_BLOCKED(LoginServerProtId.HOP_BLOCKED, 0), + INVALID_LOGIN_PACKET(LoginServerProtId.INVALID_LOGIN_PACKET, 0), + LOGINSERVER_NO_REPLY(LoginServerProtId.LOGINSERVER_NO_REPLY, 0), + LOGINSERVER_LOAD_ERROR(LoginServerProtId.LOGINSERVER_LOAD_ERROR, 0), + UNKNOWN_REPLY_FROM_LOGINSERVER(LoginServerProtId.UNKNOWN_REPLY_FROM_LOGINSERVER, 0), + IP_BLOCKED(LoginServerProtId.IP_BLOCKED, 0), + SERVICE_UNAVAILABLE(LoginServerProtId.SERVICE_UNAVAILABLE, 0), + DISALLOWED_BY_SCRIPT(LoginServerProtId.DISALLOWED_BY_SCRIPT, Prot.VAR_SHORT), + DISPLAYNAME_REQUIRED(LoginServerProtId.DISPLAYNAME_REQUIRED, 0), + NEGATIVE_CREDIT(LoginServerProtId.NEGATIVE_CREDIT, 0), + INVALID_SINGLE_SIGNON(LoginServerProtId.INVALID_SINGLE_SIGNON, 0), + NO_REPLY_FROM_SINGLE_SIGNON(LoginServerProtId.NO_REPLY_FROM_SINGLE_SIGNON, 0), + PROFILE_BEING_EDITED(LoginServerProtId.PROFILE_BEING_EDITED, 0), + NO_BETA_ACCESS(LoginServerProtId.NO_BETA_ACCESS, 0), + INSTANCE_INVALID(LoginServerProtId.INSTANCE_INVALID, 0), + INSTANCE_NOT_SPECIFIED(LoginServerProtId.INSTANCE_NOT_SPECIFIED, 0), + INSTANCE_FULL(LoginServerProtId.INSTANCE_FULL, 0), + IN_QUEUE(LoginServerProtId.IN_QUEUE, 0), + ALREADY_IN_QUEUE(LoginServerProtId.ALREADY_IN_QUEUE, 0), + BILLING_TIMEOUT(LoginServerProtId.BILLING_TIMEOUT, 0), + NOT_AGREED_TO_NDA(LoginServerProtId.NOT_AGREED_TO_NDA, 0), + EMAIL_NOT_VALIDATED(LoginServerProtId.EMAIL_NOT_VALIDATED, 0), + CONNECT_FAIL(LoginServerProtId.CONNECT_FAIL, 0), + PRIVACY_POLICY(LoginServerProtId.PRIVACY_POLICY, 0), + AUTHENTICATOR(LoginServerProtId.AUTHENTICATOR, 0), + INVALID_AUTHENTICATOR_CODE(LoginServerProtId.INVALID_AUTHENTICATOR_CODE, 0), + UPDATE_DOB(LoginServerProtId.UPDATE_DOB, 0), + TIMEOUT(LoginServerProtId.TIMEOUT, 0), + KICK(LoginServerProtId.KICK, 0), + RETRY(LoginServerProtId.RETRY, 0), + LOGIN_FAIL_1(LoginServerProtId.LOGIN_FAIL_1, 0), + LOGIN_FAIL_2(LoginServerProtId.LOGIN_FAIL_2, 0), + OUT_OF_DATE_RELOAD(LoginServerProtId.OUT_OF_DATE_RELOAD, 0), + PROOF_OF_WORK(LoginServerProtId.PROOF_OF_WORK, Prot.VAR_SHORT), + DOB_ERROR(LoginServerProtId.DOB_ERROR, 0), + WEBSITE_DOB(LoginServerProtId.WEBSITE_DOB, 0), + DOB_REVIEW(LoginServerProtId.DOB_REVIEW, 0), + CLOSED_BETA(LoginServerProtId.CLOSED_BETA, 0), +} diff --git a/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProtId.kt b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProtId.kt new file mode 100644 index 000000000..db8d1eb0c --- /dev/null +++ b/protocol/osrs-236/osrs-236-shared/src/main/kotlin/net/rsprot/protocol/common/loginprot/outgoing/prot/LoginServerProtId.kt @@ -0,0 +1,70 @@ +package net.rsprot.protocol.common.loginprot.outgoing.prot + +internal object LoginServerProtId { + /** + * TFU responses + */ + const val OK = 2 + const val INVALID_USERNAME_OR_PASSWORD = 3 + const val BANNED = 4 + const val DUPLICATE = 5 + const val CLIENT_OUT_OF_DATE = 6 + const val SERVER_FULL = 7 + const val LOGINSERVER_OFFLINE = 8 + const val IP_LIMIT = 9 + const val FORCE_PASSWORD_CHANGE = 11 + const val NEED_MEMBERS_ACCOUNT = 12 + const val INVALID_SAVE = 13 + const val UPDATE_IN_PROGRESS = 14 + const val RECONNECT_OK = 15 + const val TOO_MANY_ATTEMPTS = 16 + const val LOCKED = 18 + const val HOP_BLOCKED = 21 + const val INVALID_LOGIN_PACKET = 22 + const val LOGINSERVER_LOAD_ERROR = 24 + const val UNKNOWN_REPLY_FROM_LOGINSERVER = 25 + const val IP_BLOCKED = 26 + const val DISALLOWED_BY_SCRIPT = 29 + const val NEGATIVE_CREDIT = 32 + const val INVALID_SINGLE_SIGNON = 35 + const val NO_REPLY_FROM_SINGLE_SIGNON = 36 + const val PROFILE_BEING_EDITED = 37 + const val NO_BETA_ACCESS = 38 + const val INSTANCE_INVALID = 39 + const val INSTANCE_NOT_SPECIFIED = 40 + const val INSTANCE_FULL = 41 + const val IN_QUEUE = 42 + const val ALREADY_IN_QUEUE = 43 + const val BILLING_TIMEOUT = 44 + const val NOT_AGREED_TO_NDA = 45 + const val EMAIL_NOT_VALIDATED = 47 + const val CONNECT_FAIL = 50 + + /** + * Responses from the OSRS client. + * Namings here are guessed. + */ + const val SUCCESSFUL = 0 + const val BAD_SESSION_ID = 10 + const val IN_MEMBERS_AREA = 17 + const val CLOSED_BETA_INVITED_ONLY = 19 + const val INVALID_LOGINSERVER = 20 + const val LOGINSERVER_NO_REPLY = 23 + const val SERVICE_UNAVAILABLE = 27 + const val DISPLAYNAME_REQUIRED = 31 + const val PRIVACY_POLICY = 55 + const val AUTHENTICATOR = 56 + const val INVALID_AUTHENTICATOR_CODE = 57 + const val UPDATE_DOB = 61 + const val TIMEOUT = 62 + const val KICK = 63 + const val RETRY = 64 + const val LOGIN_FAIL_1 = 65 + const val LOGIN_FAIL_2 = 67 + const val OUT_OF_DATE_RELOAD = 68 + const val PROOF_OF_WORK = 69 + const val DOB_ERROR = 71 + const val WEBSITE_DOB = 72 + const val DOB_REVIEW = 73 + const val CLOSED_BETA = 74 +}