diff --git a/psoserv/README.md b/psoserv/README.md index f3974396..32a74ed2 100644 --- a/psoserv/README.md +++ b/psoserv/README.md @@ -8,8 +8,8 @@ the `--config=/path/to/config.json` parameter to specify a configuration file. ## Proxy Phantasmal PSO server can proxy any other PSO server. Below is a sample configuration for proxying a -locally running Tethealla patch and login server using the standard Tethealla client. Be sure to -modify tethealla.ini and set server port to 22000. +locally running Tethealla server using the standard Tethealla client. Be sure to modify +tethealla.ini and set server port to 22000. ```json { @@ -40,6 +40,24 @@ modify tethealla.ini and set server port to 22000. "version": "BB", "bindPort": 12001, "remotePort": 22001 + }, + { + "name": "ship_proxy", + "version": "BB", + "bindPort": 13000, + "remotePort": 5278 + }, + { + "name": "block_1_proxy", + "version": "BB", + "bindPort": 13001, + "remotePort": 5279 + }, + { + "name": "block_2_proxy", + "version": "BB", + "bindPort": 13002, + "remotePort": 5280 } ] } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Config.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Config.kt index 570a7392..76cc7bcc 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Config.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Config.kt @@ -2,6 +2,15 @@ package world.phantasmal.psoserv import kotlinx.serialization.Serializable +val DEFAULT_CONFIG: Config = Config( + address = null, + patch = PatchServerConfig(), + auth = ServerConfig(), + account = ServerConfig(), + proxy = null, + ships = listOf(ShipServerConfig()), +) + @Serializable class Config( val address: String? = null, @@ -9,6 +18,7 @@ class Config( val auth: ServerConfig? = null, val account: ServerConfig? = null, val proxy: ProxyConfig? = null, + val ships: List = emptyList(), ) @Serializable @@ -18,6 +28,15 @@ class ServerConfig( val port: Int? = null, ) +@Serializable +class ShipServerConfig( + val run: Boolean = true, + val name: String? = null, + val uiName: String? = null, + val address: String? = null, + val port: Int? = null, +) + @Serializable class PatchServerConfig( val run: Boolean = true, @@ -36,6 +55,7 @@ class ProxyConfig( @Serializable class ProxyServerConfig( + val run: Boolean = true, val name: String? = null, val version: GameVersionConfig, val bindAddress: String? = null, diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt index ad8086bb..812c4a19 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt @@ -19,51 +19,61 @@ private val DEFAULT_ADDRESS: Inet4Address = inet4Loopback() private const val DEFAULT_PATCH_PORT: Int = 11_000 private const val DEFAULT_LOGIN_PORT: Int = 12_000 private const val DEFAULT_ACCOUNT_PORT: Int = 12_001 +private const val DEFAULT_FIRST_SHIP_PORT: Int = 12_010 private val LOGGER = KotlinLogging.logger("main") fun main(args: Array) { - LOGGER.info { "Initializing." } + try { + LOGGER.info { "Initializing." } - var configFile: File? = null + // Try to get config file location from arguments first. + var configFile: File? = null - for (arg in args) { - val split = arg.split('=') + // Parse arguments. + for (arg in args) { + val split = arg.split('=') - if (split.size == 2) { - val (param, value) = split + if (split.size == 2) { + val (param, value) = split - when (param) { - "--config" -> { - configFile = File(value) + when (param) { + "--config" -> { + configFile = File(value) + } } } } - } - if (configFile == null) { - configFile = File("config.json").takeIf { it.isFile } - } - - val config: Config - - if (configFile != null) { - LOGGER.info { "Using configuration file $configFile." } - - val json = Json { - ignoreUnknownKeys = true + // Try default config file location if no file specified with --config argument. + if (configFile == null) { + configFile = File("config.json").takeIf { it.isFile } } - config = json.decodeFromString(configFile.readText()) - } else { - config = Config() + val config: Config + + // Parse the config file if we found one, otherwise use default config. + if (configFile != null) { + LOGGER.info { "Using configuration file $configFile." } + + val json = Json { + ignoreUnknownKeys = true + } + + config = json.decodeFromString(configFile.readText()) + } else { + config = DEFAULT_CONFIG + } + + // Initialize and start the server. + val server = initialize(config) + + LOGGER.info { "Starting up." } + + server.start() + } catch (e: Throwable) { + LOGGER.error(e) { "Failed to start up." } } - - val server = initialize(config) - - LOGGER.info { "Starting up." } - - server.start() } private class PhantasmalServer( @@ -87,39 +97,53 @@ private fun initialize(config: Config): PhantasmalServer { val accountAddress = config.account?.address?.let(::inet4Address) ?: defaultAddress val accountPort = config.account?.port ?: DEFAULT_ACCOUNT_PORT + var shipI = 1 + var shipPort = DEFAULT_FIRST_SHIP_PORT + + val ships = config.ships.filter { it.run }.map { shipCfg -> + val ship = ShipInfo( + name = shipCfg.name ?: "ship_$shipI", + uiName = shipCfg.uiName ?: "Ship $shipI", + bindPair = Inet4Pair( + shipCfg.address?.let(::inet4Address) ?: defaultAddress, + shipCfg.port ?: shipPort++, + ) + ) + shipI++ + ship + } + val servers = mutableListOf() - // If no proxy config is specified, we run a regular PSO server by default. - val run = config.proxy == null || !config.proxy.run - - if (config.patch == null && run || config.patch?.run == true) { + if (config.patch?.run == true) { val bindPair = Inet4Pair( - config.patch?.address?.let(::inet4Address) ?: defaultAddress, - config.patch?.port ?: DEFAULT_PATCH_PORT, + config.patch.address?.let(::inet4Address) ?: defaultAddress, + config.patch.port ?: DEFAULT_PATCH_PORT, ) LOGGER.info { "Configuring patch server to bind to $bindPair." } servers.add( PatchServer( - name = "patch", bindPair, - welcomeMessage = config.patch?.welcomeMessage ?: "Welcome to Phantasmal World.", + welcomeMessage = config.patch.welcomeMessage ?: "Welcome to Phantasmal World.", ) ) } - if (config.auth == null && run || config.auth?.run == true) { + if (config.auth?.run == true) { val bindPair = Inet4Pair( - config.auth?.address?.let(::inet4Address) ?: defaultAddress, - config.auth?.port ?: DEFAULT_LOGIN_PORT, + config.auth.address?.let(::inet4Address) ?: defaultAddress, + config.auth.port ?: DEFAULT_LOGIN_PORT, ) LOGGER.info { "Configuring auth server to bind to $bindPair." } + LOGGER.info { + "Auth server will redirect to account server at $accountAddress:$accountPort." + } servers.add( AuthServer( - name = "auth", bindPair, accountServerAddress = accountAddress, accountServerPort = accountPort, @@ -127,18 +151,37 @@ private fun initialize(config: Config): PhantasmalServer { ) } - if (config.account == null && run || config.account?.run == true) { + if (config.account?.run == true) { val bindPair = Inet4Pair( - config.account?.address?.let(::inet4Address) ?: defaultAddress, - config.account?.port ?: DEFAULT_ACCOUNT_PORT, + config.account.address?.let(::inet4Address) ?: defaultAddress, + config.account.port ?: DEFAULT_ACCOUNT_PORT, ) LOGGER.info { "Configuring account server to bind to $bindPair." } + LOGGER.info { + "Account server will redirect to ${ships.size} ship servers: ${ + ships.joinToString { """"${it.name}" (${it.bindPair})""" } + }." + } servers.add( AccountServer( - name = "account", bindPair, + ships, + ) + ) + } + + for (ship in ships) { + LOGGER.info { + """Configuring ship server ${ship.name} ("${ship.uiName}") to bind to ${ship.bindPair}.""" + } + + servers.add( + ShipServer( + ship.name, + ship.bindPair, + ship.uiName, ) ) } @@ -160,6 +203,10 @@ private fun initializeProxy(config: ProxyConfig): List { var nameI = 1 for (psc in config.servers) { + if (!psc.run) { + continue + } + val name = psc.name ?: "proxy_${nameI++}" val bindPair = Inet4Pair( psc.bindAddress?.let(::inet4Address) ?: defaultBindAddress, diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt index 83f8d1c4..c0283930 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt @@ -27,8 +27,12 @@ object BbMessageDescriptor : MessageDescriptor { // Sorted by low-order byte, then high-order byte. 0x0003 -> BbMessage.InitEncryption(buffer) 0x0005 -> BbMessage.Disconnect(buffer) + 0x0007 -> BbMessage.BlockList(buffer) + 0x0010 -> BbMessage.MenuSelect(buffer) 0x0019 -> BbMessage.Redirect(buffer) + 0x0083 -> BbMessage.LobbyList(buffer) 0x0093 -> BbMessage.Authenticate(buffer) + 0x00A0 -> BbMessage.ShipList(buffer) 0x01DC -> BbMessage.GuildCardHeader(buffer) 0x02DC -> BbMessage.GuildCardChunk(buffer) 0x03DC -> BbMessage.GetGuildCardChunk(buffer) @@ -76,6 +80,40 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ constructor() : this(buf(0x0005)) } + class BlockList(buffer: Buffer) : BbMessage(buffer) { + constructor(shipName: String, blocks: List) : this( + buf(0x0007, (blocks.size + 1) * 44, flags = blocks.size) { + var index = 0 + writeInt(0x00040000) // Menu type. + writeInt(index++) + writeShort(0) // Flags. + writeStringUtf16(shipName, byteLength = 34) + + for (ship in blocks) { + writeInt(MenuType.Block.toInt()) + writeInt(index++) + writeShort(0) // Flags. + writeStringUtf16(ship, byteLength = 34) + } + } + ) + } + + class MenuSelect(buffer: Buffer) : BbMessage(buffer) { + val menuType: MenuType get() = MenuType.fromInt(int(0)) + val itemNo: Int get() = int(4) + + constructor(menuType: MenuType, itemNo: Int) : this( + buf(0x0010, 8) { + writeInt(menuType.toInt()) + writeInt(itemNo) + } + ) + + override fun toString(): String = + messageString("menuType" to menuType, "itemNo" to itemNo) + } + class Redirect(buffer: Buffer) : BbMessage(buffer), RedirectMessage { override var ipAddress: ByteArray get() = byteArray(0, size = 4) @@ -108,8 +146,23 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ ) } + class LobbyList(buffer: Buffer) : BbMessage(buffer) { + constructor() : this( + buf(0x0083, 192) { + repeat(15) { + writeInt(MenuType.Lobby.toInt()) + writeInt(it + 1) // Item no. + writeInt(0) // Padding. + } + // 12 zero bytes of padding. + writeInt(0) + writeInt(0) + writeInt(0) + } + ) + } + // 0x0093 - // Also contains ignored tag, hardware info and security data. class Authenticate(buffer: Buffer) : BbMessage(buffer) { val guildCard: Int get() = int(4) val version: Short get() = short(8) @@ -118,6 +171,28 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ get() = stringAscii(offset = 20, maxByteLength = 16, nullTerminated = true) val password: String get() = stringAscii(offset = 68, maxByteLength = 16, nullTerminated = true) + val magic: Int get() = int(132) // Should be 0xDEADBEEF + val charSlot: Int get() = byte(136).toInt() + val charSelected: Boolean get() = byte(137).toInt() != 0 + } + + class ShipList(buffer: Buffer) : BbMessage(buffer) { + constructor(ships: List) : this( + buf(0x00A0, (ships.size + 1) * 44, flags = ships.size) { + var index = 0 + writeInt(MenuType.Ship.toInt()) + writeInt(index++) + writeShort(4) // Flags + writeStringUtf16("SHIP/US", byteLength = 34) + + for (ship in ships) { + writeInt(MenuType.Ship.toInt()) + writeInt(index++) + writeShort(0) // Flags + writeStringUtf16(ship, byteLength = 34) + } + } + ) } class GuildCardHeader(buffer: Buffer) : BbMessage(buffer) { @@ -183,21 +258,21 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ // 0x00E3 class CharSelect(buffer: Buffer) : BbMessage(buffer) { val slot: Int get() = uByte(0).toInt() - val select: Boolean get() = byte(4).toInt() != 0 + val selected: Boolean get() = byte(4).toInt() != 0 override fun toString(): String = - messageString("slot" to slot, "select" to select) + messageString("slot" to slot, "select" to selected) } class CharSelectAck(buffer: Buffer) : BbMessage(buffer) { - constructor(slot: Int, status: BbCharSelectStatus) : this( + constructor(slot: Int, status: CharSelectStatus) : this( buf(0x00E4, 8) { writeInt(slot) writeInt( when (status) { - BbCharSelectStatus.Update -> 0 - BbCharSelectStatus.Select -> 1 - BbCharSelectStatus.Nonexistent -> 2 + CharSelectStatus.Update -> 0 + CharSelectStatus.Select -> 1 + CharSelectStatus.Nonexistent -> 2 } ) } @@ -239,7 +314,7 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ class AuthData(buffer: Buffer) : BbMessage(buffer) { constructor( - status: BbAuthStatus, + status: AuthStatus, guildCard: Int, teamId: Int, slot: Int, @@ -248,16 +323,16 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ buf(0x00E6, 60) { writeInt( when (status) { - BbAuthStatus.Success -> 0 - BbAuthStatus.Error -> 1 - BbAuthStatus.Nonexistent -> 8 + AuthStatus.Success -> 0 + AuthStatus.Error -> 1 + AuthStatus.Nonexistent -> 8 } ) writeInt(0x10000) writeInt(guildCard) writeInt(teamId) writeInt( - if (status == BbAuthStatus.Success) (0xDEADBEEF).toInt() else 0 + if (status == AuthStatus.Success) (0xDEADBEEF).toInt() else 0 ) writeByte(slot.toByte()) writeByte(if (selected) 1 else 0) @@ -351,7 +426,7 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ } } -enum class BbAuthStatus { +enum class AuthStatus { Success, Error, Nonexistent } @@ -399,6 +474,36 @@ class FileListEntry( val filename: String, ) -enum class BbCharSelectStatus { +enum class CharSelectStatus { Update, Select, Nonexistent } + +enum class MenuType(private val type: Int) { + Lobby(-1), + InfoDesk(0), + Block(1), + Game(2), + QuestCategory(3), + Quest(4), + Ship(5), + GameType(6), + Gm(7), + Unknown(Int.MIN_VALUE); + + fun toInt(): Int = type + + companion object { + fun fromInt(type: Int): MenuType = when (type) { + -1 -> Lobby + 0 -> InfoDesk + 1 -> Block + 2 -> Game + 3 -> QuestCategory + 4 -> Quest + 5 -> Ship + 6 -> GameType + 7 -> Gm + else -> Unknown + } + } +} diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt index 07e4e743..7fe40139 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt @@ -10,9 +10,9 @@ import world.phantasmal.psoserv.messages.* import world.phantasmal.psoserv.utils.crc32Checksum class AccountServer( - name: String, bindPair: Inet4Pair, -) : GameServer(name, bindPair) { + private val ships: List, +) : GameServer("account", bindPair) { override val messageDescriptor = BbMessageDescriptor @@ -48,7 +48,7 @@ class AccountServer( teamId = message.teamId send( BbMessage.AuthData( - BbAuthStatus.Success, + AuthStatus.Success, guildCard, teamId, slot, @@ -56,6 +56,12 @@ class AccountServer( ) ) + // When the player has selected a character, we send him the list of ships to choose + // from. + if (message.charSelected) { + send(BbMessage.ShipList(ships.map { it.uiName })) + } + true } @@ -67,7 +73,7 @@ class AccountServer( } is BbMessage.CharSelect -> { - if (message.select) { + if (message.selected) { // Player has chosen a character. // TODO: Verify slot. if (slot in 0..3) { @@ -75,7 +81,7 @@ class AccountServer( charSelected = true send( BbMessage.AuthData( - BbAuthStatus.Success, + AuthStatus.Success, guildCard, teamId, slot, @@ -83,11 +89,11 @@ class AccountServer( ) ) send( - BbMessage.CharSelectAck(slot, BbCharSelectStatus.Select) + BbMessage.CharSelectAck(slot, CharSelectStatus.Select) ) } else { send( - BbMessage.CharSelectAck(slot, BbCharSelectStatus.Nonexistent) + BbMessage.CharSelectAck(slot, CharSelectStatus.Nonexistent) ) } } else { @@ -188,6 +194,19 @@ class AccountServer( true } + is BbMessage.MenuSelect -> { + if (message.menuType == MenuType.Ship) { + ships.getOrNull(message.itemNo - 1)?.let { ship -> + send(BbMessage.Redirect(ship.bindPair.address.address, ship.bindPair.port)) + } + + // Disconnect. + false + } else { + true + } + } + is BbMessage.Disconnect -> false else -> unexpectedMessage(message) diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AuthServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AuthServer.kt index 9fca3585..4a86cf0b 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AuthServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AuthServer.kt @@ -2,17 +2,16 @@ package world.phantasmal.psoserv.servers import world.phantasmal.psoserv.encryption.BbCipher import world.phantasmal.psoserv.encryption.Cipher -import world.phantasmal.psoserv.messages.BbAuthStatus +import world.phantasmal.psoserv.messages.AuthStatus import world.phantasmal.psoserv.messages.BbMessage import world.phantasmal.psoserv.messages.BbMessageDescriptor import java.net.Inet4Address class AuthServer( - name: String, bindPair: Inet4Pair, private val accountServerAddress: Inet4Address, private val accountServerPort: Int, -) : GameServer(name, bindPair) { +) : GameServer("auth", bindPair) { override val messageDescriptor = BbMessageDescriptor @@ -39,7 +38,7 @@ class AuthServer( // TODO: Actual authentication. send( BbMessage.AuthData( - BbAuthStatus.Success, + AuthStatus.Success, message.guildCard, message.teamId, slot = 0, diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/PatchServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/PatchServer.kt index feef5f9b..f81e2478 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/PatchServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/PatchServer.kt @@ -6,10 +6,9 @@ import world.phantasmal.psoserv.messages.PcMessage import world.phantasmal.psoserv.messages.PcMessageDescriptor class PatchServer( - name: String, bindPair: Inet4Pair, private val welcomeMessage: String, -) : GameServer(name, bindPair) { +) : GameServer("patch", bindPair) { override val messageDescriptor = PcMessageDescriptor diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipInfo.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipInfo.kt new file mode 100644 index 00000000..996b468c --- /dev/null +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipInfo.kt @@ -0,0 +1,7 @@ +package world.phantasmal.psoserv.servers + +class ShipInfo( + val name: String, + val uiName: String, + val bindPair: Inet4Pair, +) diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipServer.kt new file mode 100644 index 00000000..80470936 --- /dev/null +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/ShipServer.kt @@ -0,0 +1,61 @@ +package world.phantasmal.psoserv.servers + +import world.phantasmal.psoserv.encryption.BbCipher +import world.phantasmal.psoserv.encryption.Cipher +import world.phantasmal.psoserv.messages.AuthStatus +import world.phantasmal.psoserv.messages.BbMessage +import world.phantasmal.psoserv.messages.BbMessageDescriptor + +class ShipServer( + name: String, + bindPair: Inet4Pair, + private val uiName: String, +) : GameServer(name, bindPair) { + + override val messageDescriptor = BbMessageDescriptor + + override fun createCipher() = BbCipher() + + override fun createClientReceiver( + sender: ClientSender, + serverCipher: Cipher, + clientCipher: Cipher, + ): ClientReceiver = object : ClientReceiver { + init { + sender.send( + BbMessage.InitEncryption( + "Phantasy Star Online Blue Burst Game Server. Copyright 1999-2004 SONICTEAM.", + serverCipher.key, + clientCipher.key, + ), + encrypt = false, + ) + } + + override fun process(message: BbMessage): Boolean = when (message) { + is BbMessage.Authenticate -> { + // TODO: Actual authentication. + send( + BbMessage.AuthData( + AuthStatus.Success, + message.guildCard, + message.teamId, + message.charSlot, + message.charSelected, + ) + ) + send( + BbMessage.BlockList(uiName, listOf("BLOCK01")) + ) + + true + } + + else -> unexpectedMessage(message) + } + + private fun send(message: BbMessage) { + sender.send(message) + } + } +}