diff --git a/.gitignore b/.gitignore index 9935ae14a..50416bde6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ /Server/build/kotlin/ /Server/build/generated/ /Server/data/eco/bot_offers.json +/File-Server/build/ +/File-Server/build/classes/ +/File-Server/build/tmp/ +/File-Server/build/kotlin/ +/File-Server/build/generated/ **/.idea/workspace.xml **/.idea/tasks.xml Server/**/*.class diff --git a/File-Server/build.gradle b/File-Server/build.gradle new file mode 100644 index 000000000..0a41fa85e --- /dev/null +++ b/File-Server/build.gradle @@ -0,0 +1,37 @@ +apply plugin: 'application' + +archivesBaseName = 'fileserver' + +mainClassName = 'js5server.JS5Server' + +group 'org.rs09' +version '1.0.0' + +repositories { + mavenCentral() +} + +dependencies { + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2' + /* Networking */ + implementation "io.ktor:ktor-server-core:1.5.0" + implementation "io.ktor:ktor-network:1.5.0" + + // Cache ops + implementation 'com.displee:rs-cache-library:6.8' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' +} + +test { + useJUnitPlatform() +} + +jar { + manifest { + attributes 'Main-Class': 'js5server.JS5Server' + } + from { configurations.compileClasspath.collect { it.isDirectory() ? it : zipTree(it) } } +} \ No newline at end of file diff --git a/File-Server/file-server.properties b/File-Server/file-server.properties new file mode 100644 index 000000000..be0b9d8c3 --- /dev/null +++ b/File-Server/file-server.properties @@ -0,0 +1,4 @@ +revision=530 +subrevision=1 +port=43593 +cachePath= \ No newline at end of file diff --git a/File-Server/src/main/kotlin/js5server/DataProvider.kt b/File-Server/src/main/kotlin/js5server/DataProvider.kt new file mode 100644 index 000000000..4ea50802f --- /dev/null +++ b/File-Server/src/main/kotlin/js5server/DataProvider.kt @@ -0,0 +1,18 @@ +package js5server + +import com.displee.cache.CacheLibrary + +interface DataProvider { + fun data(index: Int, archive: Int): ByteArray? + + companion object { + operator fun invoke(cache: CacheLibrary) = object : DataProvider { + override fun data(index: Int, archive: Int) = + if (index == 255) { + cache.index255?.readArchiveSector(archive)?.data + } else { + cache.index(index).readArchiveSector(archive)?.data + } + } + } +} \ No newline at end of file diff --git a/File-Server/src/main/kotlin/js5server/FileServer.kt b/File-Server/src/main/kotlin/js5server/FileServer.kt new file mode 100644 index 000000000..8ea4ef5c1 --- /dev/null +++ b/File-Server/src/main/kotlin/js5server/FileServer.kt @@ -0,0 +1,111 @@ +package js5server + +import com.displee.cache.CacheLibrary +import io.ktor.utils.io.* +import js5server.ext.readUMedium +import kotlin.math.min + +class FileServer( + private val provider: DataProvider, + private val versionTable: ByteArray +) { + + /** + * Fulfills a request by sending the requested files data to the requester + */ + suspend fun fulfill(read: ByteReadChannel, write: ByteWriteChannel, prefetch: Boolean) { + val value = read.readUMedium() + val index = value shr 16 + val archive = value and 0xffff + val data = data(index, archive) ?: return println("Unable to fulfill request $index $archive $prefetch.") + if (index == 255 && archive == 255) { + serve255(write, data) + } else { + serve(write, index, archive, data, prefetch) + } + } + + /** + * @return data for an [index]'s [archive] file or [versionTable] when index and archive are both 255 + */ + fun data(index: Int, archive: Int): ByteArray? { + if (index == 255 && archive == 255) { + return versionTable + } + return provider.data(index, archive) + } + + /** + * Writes [source] [offset] [size] to [write] and starting at [headerSize] inserting a [SEPARATOR] every [split] bytes + */ + private suspend fun serve255(write: ByteWriteChannel, source: ByteArray) { + write.writeByte(255) + write.writeShort(255) + write.writeByte(0) + write.writeInt(source.size) + write.writeFully(source) + } + + /** + * Writes response header followed by the contents of [data] to [write] + */ + suspend fun serve(write: ByteWriteChannel, index: Int, archive: Int, data: ByteArray, prefetch: Boolean) { + val compression = data[0].toInt() + val size = getInt(data[1], data[2], data[3], data[4]) + if (compression != 0) 8 else 4 + write.writeByte(index) + write.writeShort(archive) + write.writeByte(if (prefetch) compression or 0x80 else compression) + serve(write, HEADER, data, OFFSET, size, SPLIT) + } + + /** + * Writes [source] [offset] [size] to [write] and starting at [headerSize] inserting a [SEPARATOR] every [split] bytes + */ + suspend fun serve(write: ByteWriteChannel, headerSize: Int, source: ByteArray, offset: Int, size: Int, split: Int) { + var length = min(size, split - headerSize) + write.writeFully(source, offset, length) + var written = length + while (written < size) { + write.writeByte(SEPARATOR) + + length = if (size - written < split) size - written else split - 1 + write.writeFully(source, written + offset, length) + written += length + } + } + + companion object { + + private fun getInt(b1: Byte, b2: Byte, b3: Byte, b4: Byte) = b1.toInt() shl 24 or (b2.toInt() and 0xff shl 16) or (b3.toInt() and 0xff shl 8) or (b4.toInt() and 0xff) + + private const val SEPARATOR = 255 + private const val HEADER = 4 + private const val SPLIT = 512 + private const val OFFSET = 1 + } + + /** + * Length of archive with [name] in [index] + */ + fun file(cacheLibrary: CacheLibrary, index: Int, name: String): Int { + val idx = cacheLibrary.index(index) + val archive = idx.archiveId(name) + if (archive == -1) { + return 0 + } + return (idx.readArchiveSector(archive)?.size ?: 2) - 2 + } + + /** + * Length of all archives in [index] + */ + fun archive(cache: CacheLibrary, index: Int): Int { + var total = 0 + val idx = cache.index(index) + idx.archiveIds().forEach { archive -> + total += idx.readArchiveSector(archive)?.size ?: 0 + } + total += cache.index255?.readArchiveSector(index)?.size ?: 0 + return total + } +} \ No newline at end of file diff --git a/File-Server/src/main/kotlin/js5server/JS5Net.kt b/File-Server/src/main/kotlin/js5server/JS5Net.kt new file mode 100644 index 000000000..16c6f3587 --- /dev/null +++ b/File-Server/src/main/kotlin/js5server/JS5Net.kt @@ -0,0 +1,152 @@ +package js5server + +import io.ktor.network.selector.* +import io.ktor.network.sockets.* +import io.ktor.utils.io.* +import js5server.ext.readMedium +import kotlinx.coroutines.* +import java.util.concurrent.Executors + +class JS5Net( + private val server: FileServer, + private val revision: Int, + private val subRevision: Int +) { + + private val exceptionHandler = CoroutineExceptionHandler { context, throwable -> + System.err.println("$context | $throwable") + } + + private lateinit var dispatcher: ExecutorCoroutineDispatcher + private var running = false + + /** + * Start the server and begin creating a new coroutine for every new connection accepted + * @param threads a fixed number or 0 to dynamically allocate based on need + */ + fun start(port: Int, threads: Int) = runBlocking { + val executor = if (threads == 0) Executors.newCachedThreadPool() else Executors.newFixedThreadPool(threads) + dispatcher = executor.asCoroutineDispatcher() + val selector = ActorSelectorManager(dispatcher) + val supervisor = SupervisorJob() + val scope = CoroutineScope(coroutineContext + supervisor + exceptionHandler) + with(scope) { + val server = aSocket(selector).tcp().bind(port = port) + running = true + while (running) { + val socket = server.accept() + launch(Dispatchers.IO) { + connect(socket) + } + } + } + } + + suspend fun connect(socket: Socket) { + val read = socket.openReadChannel() + val write = socket.openWriteChannel(autoFlush = true) + synchronise(read, write) + if (acknowledge(read, write)) { + readRequests(read, write) + } + } + + /** + * If the client is up-to-date and in the correct state send it the [prefetchKeys] list so it knows what indices are available to request + */ + private suspend fun synchronise(read: ByteReadChannel, write: ByteWriteChannel) { + val opcode = read.readByte().toInt() + println("Received $opcode") + if (opcode != HANDSHAKE_REQUEST) { + write.writeByte(REJECT_SESSION) + write.close() + return + } + + val revision = read.readInt() + val version = read.readInt() + println("Received ${this.revision}.${this.subRevision} | ${revision}.$version") + if (revision != this.revision || version != this.subRevision) { + write.writeByte(GAME_UPDATED) + write.close() + return + } + write.writeByte(0) + } + + /** + * Confirm the client got our message and is ready to start sending file requests + */ + private suspend fun acknowledge(read: ByteReadChannel, write: ByteWriteChannel): Boolean { + val opcode = read.readByte().toInt() + println("Received $opcode") + if (opcode != ACKNOWLEDGE) { + write.writeByte(REJECT_SESSION) + write.close() + return false + } + + return verify(read, write, ACKNOWLEDGE_ID) + } + + /** + * Confirm a session value send by the client is as the server [expected] + */ + private suspend fun verify(read: ByteReadChannel, write: ByteWriteChannel, expected: Int): Boolean { + val id = read.readMedium() + if (id != expected) { + write.writeByte(BAD_SESSION_ID) + write.close() + return false + } + return true + } + + private suspend fun readRequests(read: ByteReadChannel, write: ByteWriteChannel) = coroutineScope { + try { + while (isActive) { + readRequest(read, write) + } + } finally { + println("Client disconnected js5") + } + } + + /** + * Verify status updates and pass requests onto the [server] to fulfill + */ + private suspend fun readRequest(read: ByteReadChannel, write: ByteWriteChannel) { + when (val opcode = read.readByte().toInt()) { + STATUS_LOGGED_OUT, STATUS_LOGGED_IN -> verify(read, write, STATUS_ID) + PRIORITY_REQUEST, PREFETCH_REQUEST -> server.fulfill(read, write, opcode == PREFETCH_REQUEST) + else -> { + println("Closing write") + write.close() + } + } + } + + fun stop() { + running = false + dispatcher.close() + } + + companion object { + // Session ids + const val ACKNOWLEDGE_ID = 3 + const val STATUS_ID = 0 + + // Opcodes + const val PREFETCH_REQUEST = 0 + const val PRIORITY_REQUEST = 1 + const val HANDSHAKE_REQUEST = 15 + const val STATUS_LOGGED_IN = 2 + const val STATUS_LOGGED_OUT = 3 + const val ACKNOWLEDGE = 6 + + // Response codes + private const val GAME_UPDATED = 6 + private const val BAD_SESSION_ID = 10 + private const val REJECT_SESSION = 11 + } +} \ No newline at end of file diff --git a/File-Server/src/main/kotlin/js5server/JS5Server.kt b/File-Server/src/main/kotlin/js5server/JS5Server.kt new file mode 100644 index 000000000..f9a88cd32 --- /dev/null +++ b/File-Server/src/main/kotlin/js5server/JS5Server.kt @@ -0,0 +1,52 @@ +package js5server + +import com.displee.cache.CacheLibrary +import java.io.File +import kotlin.concurrent.thread + +object JS5Server { + + @JvmStatic + fun main(args: Array) { + val start = System.currentTimeMillis() + val file = File("./file-server.properties") + if (!file.exists()) { + System.err.println("Unable to find server properties file.") + return + } + + println("Start up...") + + var revision = 530 + var subRevision = 1 + var port = 43593 + var threads = 0 + lateinit var cachePath: String + file.forEachLine { line -> + val (key, value) = line.split("=") + when (key) { + "revision" -> revision = value.toInt() + "subrevision" -> subRevision = value.toInt() + "port" -> port = value.toInt() + "threads" -> threads = value.toInt() + "cachePath" -> cachePath = value + } + } + + println("Loaded configuration.") + + val cache = CacheLibrary(cachePath) + val versionTable = cache.generateOldUkeys() + val fileServer = FileServer(DataProvider(cache), versionTable) + + println("Loaded cache revision $revision from $cachePath") + + val network = JS5Net(fileServer, revision, subRevision) + + println("Loading complete [${System.currentTimeMillis() - start}ms] / bound to 127.0.0.1:$port") + + val runtime = Runtime.getRuntime() + runtime.addShutdownHook(thread(start = false) { network.stop() }) + network.start(port, threads) + } +} \ No newline at end of file diff --git a/File-Server/src/main/kotlin/js5server/ext/JagexTypes.kt b/File-Server/src/main/kotlin/js5server/ext/JagexTypes.kt new file mode 100644 index 000000000..ee356903e --- /dev/null +++ b/File-Server/src/main/kotlin/js5server/ext/JagexTypes.kt @@ -0,0 +1,88 @@ +package js5server.ext + +import io.ktor.utils.io.* +import io.ktor.utils.io.core.* +import java.nio.ByteBuffer +import kotlin.text.toByteArray + +private val CHARSET = charset("UTF-8") + +suspend fun ByteReadChannel.readMedium(): Int { + return (readByte().toInt() shl 16) + (readByte().toInt() shl 8) + readByte().toInt() +} + +suspend fun ByteReadChannel.readUByte(): Int { + return readByte().toInt() and 0xff +} + +suspend fun ByteReadChannel.readUMedium(): Int { + return (readUByte() shl 16) + (readUByte() shl 8) + readUByte() +} + +fun ByteBuffer.putSmart(value: Int) { + if (value >= 128) { + putShort((value + 32768).toShort()) + } else { + put(value.toByte()) + } +} + +fun ByteBuffer.putJagexString(string: String) { + put(0) + put(string.toByteArray(CHARSET)) + put(0) +} + +fun ByteBuffer.putShortA(value: Int) { + put(((value shr 8) and 0xFFFF).toByte()) + put(((value + 128) and 0xFF).toByte()) +} + +fun ByteBuffer.putIntB(value: Int) { + put((value shr 16).toByte()) + put((value shr 24).toByte()) + put((value).toByte()) + put((value shr 8).toByte()) +} + +fun ByteBuffer.putUnsignedByteS(value: Byte) { + put(((value + 128) and 0xFF).toByte()) +} + +fun ByteBuffer.putUnsignedShort(value: Int) { + putShort(((value) and 0xFFFF).toShort()) +} + +fun BytePacketBuilder.writeUnsignedByteSubtract(value: Int) { + writeByte(((value + 128) and 0xFF).toByte()) +} + +fun BytePacketBuilder.writeUnsignedShort(value: Int) { + writeShort(((value) and 0xFFFF).toShort()) +} + +fun BytePacketBuilder.writeShortAdd(value: Int) { + writeByte((value shr 8).toByte()) + writeByte((value + 128).toByte()) +} + +fun BytePacketBuilder.writeSmart(value: Int) { + if (value >= 128) { + writeShort((value + 32768).toShort()) + } else { + writeByte(value.toByte()) + } +} + +fun BytePacketBuilder.writeIntME(value: Int) { + writeByte((value shr 16).toByte()) + writeByte((value shr 24).toByte()) + writeByte(value.toByte()) + writeByte((value shr 8).toByte()) +} + +fun BytePacketBuilder.writeVersionedString(value: String) { + writeByte(0) + writeText(value) + writeByte(0) +} \ No newline at end of file diff --git a/Server/src/main/java/core/game/node/entity/skill/cooking/CookingRewrite.kt b/Server/src/main/java/core/game/node/entity/skill/cooking/CookingRewrite.kt index e7cec5cd2..356da297c 100644 --- a/Server/src/main/java/core/game/node/entity/skill/cooking/CookingRewrite.kt +++ b/Server/src/main/java/core/game/node/entity/skill/cooking/CookingRewrite.kt @@ -3,16 +3,15 @@ package core.game.node.entity.skill.cooking import core.game.node.`object`.GameObject import core.game.node.entity.player.Player import core.game.node.item.Item -import core.plugin.Initializable import org.rs09.consts.Items import org.rs09.consts.Items.BREAD_DOUGH_2307 import org.rs09.consts.Items.RAW_BEEF_2132 import org.rs09.consts.Items.SEAWEED_401 import org.rs09.consts.Items.UNCOOKED_CAKE_1889 import rs09.game.interaction.InteractionListener +import rs09.game.node.entity.skill.cooking.CookingDialogue //author: Ceik -@Initializable class CookingRewrite : InteractionListener() { val RAW_FOODS: IntArray @@ -31,11 +30,11 @@ class CookingRewrite : InteractionListener() { val range = obj.name.toLowerCase().contains("range") when (item.id) { RAW_BEEF_2132 -> if (range) { - player.dialogueInterpreter.open(FoodCookingDialogue.DialogueID, item, 9436, true, obj) + player.dialogueInterpreter.open(CookingDialogue(item,9436,true,obj)) return@onUseWith true } SEAWEED_401 -> if (range) { - player.dialogueInterpreter.open(FoodCookingDialogue.DialogueID, item, 1781, false, obj) + player.dialogueInterpreter.open(CookingDialogue(item,1781,false,obj)) return@onUseWith true } BREAD_DOUGH_2307, UNCOOKED_CAKE_1889 -> if (!range) { @@ -45,7 +44,7 @@ class CookingRewrite : InteractionListener() { } //cook a standard item - player.dialogueInterpreter.open(FoodCookingDialogue.DialogueID, item.id, obj) + player.dialogueInterpreter.open(CookingDialogue(item.id,obj)) return@onUseWith true } diff --git a/Server/src/main/java/core/game/node/entity/skill/cooking/FoodCookingDialogue.java b/Server/src/main/java/core/game/node/entity/skill/cooking/FoodCookingDialogue.java deleted file mode 100644 index 2d644e655..000000000 --- a/Server/src/main/java/core/game/node/entity/skill/cooking/FoodCookingDialogue.java +++ /dev/null @@ -1,125 +0,0 @@ -package core.game.node.entity.skill.cooking; - -import core.cache.def.impl.ItemDefinition; -import core.game.content.dialogue.DialoguePlugin; -import core.game.node.entity.player.Player; -import core.game.node.entity.player.link.RunScript; -import core.game.node.object.GameObject; -import core.net.packet.PacketRepository; -import core.net.packet.context.ChildPositionContext; -import core.net.packet.out.RepositionChild; - -public class FoodCookingDialogue extends DialoguePlugin { - public static final int DialogueID = 1238503; - private int initial,product; - private boolean sinew = false; - private GameObject object; - - public FoodCookingDialogue(){ - /** - * Empty - */ - } - public FoodCookingDialogue(Player player){super(player);} - - @Override - public DialoguePlugin newInstance(Player player) { - return new FoodCookingDialogue(player); - } - - @Override - public boolean open(Object... args) { - stage = 0; - switch(args.length){ - case 2: - initial = (int) args[0]; - if(CookableItems.intentionalBurn(initial)){ // checks intentional burning - product = CookableItems.getIntentionalBurn(initial).getId(); - } else { - product = CookableItems.forId(initial).cooked; - - } - object = (GameObject) args[1]; - break; - case 4: - initial = (int) args[0]; - product = (int) args[1]; - sinew = (boolean) args[2]; - object = (GameObject) args[3]; - if(sinew){ - player.getDialogueInterpreter().sendOptions("Select one","Dry the meat into sinew","Cook the meat"); - stage = 100; - return true; - } - break; - } - display(); - return true; - } - - @Override - public boolean handle(int interfaceId, int buttonId) { - switch(stage){ - case 0: - end(); - int amount = getAmount(buttonId); - switch(amount){ - case -1: - player.setAttribute("runscript", new RunScript() { - @Override - public boolean handle() { - int amount = (int) value; - CookingRewrite.cook(player, object, initial, product, amount); - return false; - } - }); - player.getDialogueInterpreter().sendInput(false,"Enter an amount:"); - break; - default: - end(); - CookingRewrite.cook(player,object,initial,product,amount); - break; - } - break; - case 100: - switch(buttonId){ - case 1: - CookingRewrite.cook(player,object,initial,product,1); - break; - case 2: - product = CookableItems.forId(initial).cooked; - display(); - break; - } - } - return true; - } - - private final int getAmount(final int buttonId) { - switch (buttonId) { - case 5: - return 1; - case 4: - return 5; - case 3: - return -1; - case 2: - return player.getInventory().getAmount(initial); - } - return -1; - } - - public void display() { - player.getInterfaceManager().openChatbox(307); - PacketRepository.send(RepositionChild.class, new ChildPositionContext(player, 307, 3, 60, 90)); - PacketRepository.send(RepositionChild.class, new ChildPositionContext(player,307,2,208,20)); - player.getPacketDispatch().sendItemZoomOnInterface(product, 160, 307, 2); - player.getPacketDispatch().sendString(ItemDefinition.forId(product).getName(), 307, 3); - stage = 0; - } - - @Override - public int[] getIds() { - return new int[] {DialogueID}; - } -} diff --git a/Server/src/main/kotlin/rs09/game/node/entity/skill/cooking/CookingDialogue.kt b/Server/src/main/kotlin/rs09/game/node/entity/skill/cooking/CookingDialogue.kt new file mode 100644 index 000000000..12b1432b8 --- /dev/null +++ b/Server/src/main/kotlin/rs09/game/node/entity/skill/cooking/CookingDialogue.kt @@ -0,0 +1,103 @@ +package rs09.game.node.entity.skill.cooking + +import core.cache.def.impl.ItemDefinition +import core.game.node.`object`.GameObject +import core.game.node.entity.player.link.RunScript +import core.game.node.entity.skill.cooking.CookableItems +import core.game.node.entity.skill.cooking.CookingRewrite.Companion.cook +import core.net.packet.PacketRepository +import core.net.packet.context.ChildPositionContext +import core.net.packet.out.RepositionChild +import rs09.game.content.dialogue.DialogueFile +import rs09.tools.START_DIALOGUE + +class CookingDialogue(vararg val args: Any) : DialogueFile(){ + var initial = 0 + var product = 0 + var `object`: GameObject? = null + var sinew = false + override fun handle(componentID: Int, buttonID: Int) { + when(stage){ + START_DIALOGUE -> { + when (args.size) { + 2 -> { + initial = args.get(0) as Int + if (CookableItems.intentionalBurn(initial)) { // checks intentional burning + product = CookableItems.getIntentionalBurn(initial).id + } else { + product = CookableItems.forId(initial).cooked + } + `object` = args.get(1) as GameObject + } + 4 -> { + initial = args.get(0) as Int + product = args.get(1) as Int + sinew = args.get(2) as Boolean + `object` = args.get(3) as GameObject + if (sinew) { + player!!.dialogueInterpreter.sendOptions( + "Select one", + "Dry the meat into sinew", + "Cook the meat" + ) + stage = 100 + return + } + } + } + display() + } + + 1 -> { + end() + val amount = getAmount(buttonID) + when (amount) { + -1 -> { + player!!.setAttribute("runscript", object : RunScript() { + override fun handle(): Boolean { + val amount = value as Int + cook(player, `object`, initial, product, amount) + return false + } + }) + player!!.dialogueInterpreter.sendInput(false, "Enter an amount:") + } + else -> { + end() + cook(player!!, `object`, initial, product, amount) + } + } + } + + 100 -> { + when (buttonID) { + 1 -> cook(player!!, `object`, initial, product, 1) + 2 -> { + product = CookableItems.forId(initial).cooked + display() + } + } + } + } + } + + private fun getAmount(buttonId: Int): Int { + when (buttonId) { + 5 -> return 1 + 4 -> return 5 + 3 -> return -1 + 2 -> return player!!.inventory.getAmount(initial) + } + return -1 + } + + fun display() { + player!!.interfaceManager.openChatbox(307) + PacketRepository.send(RepositionChild::class.java, ChildPositionContext(player, 307, 3, 60, 90)) + PacketRepository.send(RepositionChild::class.java, ChildPositionContext(player, 307, 2, 208, 20)) + player!!.packetDispatch.sendItemZoomOnInterface(product, 160, 307, 2) + player!!.packetDispatch.sendString(ItemDefinition.forId(product).name, 307, 3) + stage = 1 + } + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 3d2c55f5d..5251a7a7a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ rootProject.name = 'RS09' include 'Client' include 'Management-Server' -include 'Server' \ No newline at end of file +include 'Server' +include 'File-Server' \ No newline at end of file