From c2f50e682710cb34c1acc6dc6eb701774cc2f13b Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Wed, 25 Aug 2021 21:19:21 +0200 Subject: [PATCH] Added lobbies and party creation. Broadcast messages are now broadcast to other clients (without verification). --- .../world/phantasmal/psolib/cursor/Cursor.kt | 8 +- .../world/phantasmal/psolib/buffer/Buffer.kt | 2 +- .../kotlin/world/phantasmal/psoserv/Main.kt | 12 +- .../world/phantasmal/psoserv/data/Account.kt | 108 ---- .../phantasmal/psoserv/data/AccountStore.kt | 56 --- .../world/phantasmal/psoserv/data/Data.kt | 473 ++++++++++++++++++ .../phantasmal/psoserv/messages/BbMessages.kt | 241 +++++++-- .../phantasmal/psoserv/messages/Messages.kt | 6 + .../psoserv/servers/AccountServer.kt | 36 +- .../phantasmal/psoserv/servers/BlockServer.kt | 312 ++++++++---- .../phantasmal/psoserv/servers/GameServer.kt | 2 +- .../phantasmal/psoserv/servers/Server.kt | 1 + .../psoserv/servers/SocketHandler.kt | 22 +- 13 files changed, 957 insertions(+), 322 deletions(-) delete mode 100644 psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Account.kt delete mode 100644 psoserv/src/main/kotlin/world/phantasmal/psoserv/data/AccountStore.kt create mode 100644 psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Data.kt diff --git a/psolib/src/commonMain/kotlin/world/phantasmal/psolib/cursor/Cursor.kt b/psolib/src/commonMain/kotlin/world/phantasmal/psolib/cursor/Cursor.kt index f3611518..e3f8d1e9 100644 --- a/psolib/src/commonMain/kotlin/world/phantasmal/psolib/cursor/Cursor.kt +++ b/psolib/src/commonMain/kotlin/world/phantasmal/psolib/cursor/Cursor.kt @@ -118,8 +118,8 @@ interface Cursor { */ fun stringAscii( maxByteLength: Int, - nullTerminated: Boolean, - dropRemaining: Boolean, + nullTerminated: Boolean = true, + dropRemaining: Boolean = true, ): String /** @@ -127,8 +127,8 @@ interface Cursor { */ fun stringUtf16( maxByteLength: Int, - nullTerminated: Boolean, - dropRemaining: Boolean, + nullTerminated: Boolean = true, + dropRemaining: Boolean = true, ): String /** diff --git a/psolib/src/jvmMain/kotlin/world/phantasmal/psolib/buffer/Buffer.kt b/psolib/src/jvmMain/kotlin/world/phantasmal/psolib/buffer/Buffer.kt index 210594cb..838b341a 100644 --- a/psolib/src/jvmMain/kotlin/world/phantasmal/psolib/buffer/Buffer.kt +++ b/psolib/src/jvmMain/kotlin/world/phantasmal/psolib/buffer/Buffer.kt @@ -100,7 +100,7 @@ actual class Buffer private constructor( for (i in 0 until len) { val codePoint = buf.getChar(offset + i * 2) - if (nullTerminated && codePoint == '0') { + if (nullTerminated && codePoint == '\u0000') { break } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt index 9e4fdc52..1111e710 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/Main.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.hocon.Hocon import kotlinx.serialization.hocon.decodeFromConfig import mu.KotlinLogging -import world.phantasmal.psoserv.data.AccountStore +import world.phantasmal.psoserv.data.Store import world.phantasmal.psoserv.encryption.BbCipher import world.phantasmal.psoserv.encryption.Cipher import world.phantasmal.psoserv.encryption.PcCipher @@ -74,8 +74,8 @@ fun main(args: Array) { } // Initialize and start the server. - val accountStore = AccountStore(LOGGER) - val servers = initialize(config, accountStore) + val store = Store(LOGGER) + val servers = initialize(config, store) if (start) { if (servers.isEmpty()) { @@ -93,7 +93,7 @@ fun main(args: Array) { } } -private fun initialize(config: Config, accountStore: AccountStore): List { +private fun initialize(config: Config, store: Store): List { val address = config.address?.let(::inet4Address) ?: DEFAULT_ADDRESS LOGGER.info { "Binding to $address." } @@ -184,7 +184,7 @@ private fun initialize(config: Config, accountStore: AccountStore): List servers.add( AccountServer( - accountStore, + store, bindPair, ships, ) @@ -213,7 +213,7 @@ private fun initialize(config: Config, accountStore: AccountStore): List servers.add( BlockServer( - accountStore, + store, block.name, block.bindPair, blockId = index + 1, diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Account.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Account.kt deleted file mode 100644 index 9fc2518c..00000000 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Account.kt +++ /dev/null @@ -1,108 +0,0 @@ -package world.phantasmal.psoserv.data - -import mu.KLogger - -class AccountData( - private val logger: KLogger, - account: Account, - playing: PlayingAccount?, - private val password: String, - private var loggedIn: Boolean, -) { - /** - * All operations on this object must synchronize on this lock. All exposed data must be deeply - * immutable and represent a consistent snapshot of the object's state at the time of retrieval. - */ - private val lock = Any() - private var _account = account - private var _playing = playing - - val account: Account get() = synchronized(lock) { _account } - val playing: PlayingAccount? get() = synchronized(lock) { _playing } - - init { - require(password.length <= 16) - } - - fun logIn(password: String): LogInResult = - synchronized(lock) { - if (password != this.password) { - LogInResult.BadPassword - } else if (loggedIn) { - LogInResult.AlreadyLoggedIn - } else { - loggedIn = true - LogInResult.Ok - } - } - - fun logOut() { - synchronized(lock) { - if (!loggedIn) { - logger.warn { - """Trying to log out account ${account.id} "${account.username}" while it wasn't logged in.""" - } - } - - _playing = null - loggedIn = false - } - } - - fun setPlaying(char: Character, blockId: Int) { - synchronized(lock) { - _playing = PlayingAccount(account, char, blockId) - loggedIn = true - } - } -} - -class Account( - val id: Long, - val username: String, - val guildCardNo: Int, - val teamId: Int, - val characters: List, -) { - init { - require(username.length <= 16) - } -} - -class PlayingAccount( - val account: Account, - val char: Character, - val blockId: Int, -) - -class Character( - val id: Long, - val accountId: Long, - val name: String, - val sectionId: SectionId, - val exp: Int, - val level: Int, -) { - init { - require(name.length <= 16) - require(exp >= 0) - require(level in 1..200) - } -} - -enum class SectionId { - Viridia, - Greenill, - Skyly, - Bluefull, - Purplenum, - Pinkal, - Redria, - Oran, - Yellowboze, - Whitill, -} - -enum class LogInResult { - Ok, BadPassword, AlreadyLoggedIn -} diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/AccountStore.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/AccountStore.kt deleted file mode 100644 index 2eb182c6..00000000 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/AccountStore.kt +++ /dev/null @@ -1,56 +0,0 @@ -package world.phantasmal.psoserv.data - -import mu.KLogger - -class AccountStore(private val logger: KLogger) { - /** - * All operations on this object must synchronize on this lock. - */ - private val lock = Any() - private var nextId: Long = 1L - private var nextGuildCardNo: Int = 1 - - private val idToAccountData = mutableMapOf() - private val usernameToAccountData = mutableMapOf() - - fun getAccountData(username: String, password: String): AccountData = - synchronized(lock) { - // Simply create the account if it doesn't exist yet. - usernameToAccountData.getOrPut(username) { - val accountId = nextId++ - AccountData( - logger = logger, - account = Account( - id = accountId, - username = username, - guildCardNo = nextGuildCardNo++, - teamId = 1337, - characters = listOf( - Character( - id = nextId++, - accountId = accountId, - name = "${username.take(14)} 1", - sectionId = SectionId.Viridia, - exp = 1_000_000, - level = 200, - ) - ), - ), - playing = null, - password = password, - loggedIn = false, - ).also { - // Ensure it can also be found by ID. - idToAccountData[accountId] = it - } - } - } - - fun getPlayingAccountsForBlock(blockId: Int): List = - synchronized(lock) { - idToAccountData.values.asSequence() - .mapNotNull { it.playing } // Map before filtering to avoid race condition. - .filter { it.blockId == blockId } - .toList() - } -} diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Data.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Data.kt new file mode 100644 index 00000000..b46384f9 --- /dev/null +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/data/Data.kt @@ -0,0 +1,473 @@ +package world.phantasmal.psoserv.data + +import mu.KLogger +import world.phantasmal.psolib.Episode +import world.phantasmal.psoserv.messages.Message +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/* + * Whenever data is changed in these classes, locks should be acquired in this order: + * 1. Store + * 2. LobbyOrParty + * 3. Client. + */ + +private const val MAX_ACCOUNTS_PER_LOBBY: Int = 20 +private const val MAX_ACCOUNTS_PER_PARTY: Int = 4 + +@RequiresOptIn(message = "This API is internal and should not be accessed from outside the file it was defined in.") +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) +private annotation class Internal + +// TODO: Periodically log out stale accounts. +// TODO: Periodically remove stale parties (lobbies too?). +@OptIn(Internal::class) +class Store(private val logger: KLogger) { + /** + * All operations on this object must synchronize on this lock. + */ + private val lock = Any() + private var nextAccountId: Long = 1L + private var nextCharId: Long = 1L + private var nextGuildCardNo: Int = 1 + + private val nameToClient = mutableMapOf() + + private val blockIdToLobbyIdToLobby = mutableMapOf>() + private var nextPartyId: Int = 1 + private val blockIdToNameToParty = mutableMapOf>() + + fun authenticate(name: String, password: String, sendMessage: (Message) -> Unit): AuthResult { + val client = synchronized(lock) { + // Simply create the account and client if it doesn't exist yet. + nameToClient.getOrPut(name) { + val accountId = nextAccountId++ + Client( + logger = logger, + account = Account( + id = accountId, + name = name, + guildCardNo = nextGuildCardNo++, + teamId = 1337, + characters = listOf( + Character( + id = nextCharId++, + accountId = accountId, + name = "${name.take(14)} 1", + sectionId = SectionId.Viridia, + exp = 1_000_000, + level = 200, + ) + ), + ), + password = password, + ) + } + } + + return client.logIn(password, sendMessage) + } + + fun logOut(client: Client) { + synchronized(lock) { + synchronizedClientAndLop(client) { + val lop = client.lop + + if (lop is Party) { + lop.removeClient(client) + + if (lop.isEmpty) { + val nameToParty = blockIdToNameToParty[lop.blockId] + ?: return + + nameToParty.remove(lop.details.name, lop) + } + } + + client.logOut() + } + } + } + + fun getLobbies(blockId: Int): List = + synchronized(lock) { + blockIdToLobbyIdToLobby.getOrPut(blockId) { + // Create lobbies if necessary. + // BlueBurst needs precisely 15 lobbies. It seems like they need to have IDs 0..14. + (0..14).map { lobbyId -> + Lobby(lobbyId, blockId) + } + } + } + + /** Returns null if no lobbies are available or [client] is already in a lobby or party. */ + fun joinFirstAvailableLobby(blockId: Int, client: Client): Lobby? = + synchronized(lock) { + val lobbies = getLobbies(blockId) + + for (lobby in lobbies) { + synchronized(lobby) { + // Do unnecessary check here, so we don't have to lock on client everytime. + if (!lobby.isFull) { + synchronized(client.lock) { + if (client.lop != null) { + return null + } + + val id = lobby.addClient(client) + + // Can't be -1 at this point. + if (id.toInt() != -1) { + client.setLop(lobby, id) + return lobby + } + } + } + } + } + + return null + } + + /** + * Creates a new party and adds [leader] to it. Return null if a party with [name] already + * exists. + */ + fun createAndJoinParty( + blockId: Int, + name: String, + password: String, + difficulty: Difficulty, + episode: Episode, + mode: Mode, + leader: Client, + ): CreateAndJoinPartyResult { + synchronized(lock) { + val nameToParty = blockIdToNameToParty.getOrPut(blockId, ::mutableMapOf) + + if (name in nameToParty) { + return CreateAndJoinPartyResult.NameInUse + } + + val details = PartySettings(name, password, difficulty, episode, mode) + val party = Party(nextPartyId++, blockId, details) + + synchronized(party.lock) { + synchronizedClientAndLop(leader) { + if (leader.lop is Party) { + return CreateAndJoinPartyResult.AlreadyInParty + } + + leader.lop?.removeClient(leader) + val id = party.addClient(leader) + check(id.toInt() != -1) { "Couldn't add client to newly created party." } + leader.setLop(party, id) + } + } + + // Do this at the last possible time, so any exceptions and early returns can prevent + // the party from being added to the store. + nameToParty[name] = party + + return CreateAndJoinPartyResult.Ok(party) + } + } + + /** + * Acquires the LOP lock and then the client lock. + */ + private inline fun synchronizedClientAndLop( + client: Client, + block: () -> T, + ): T { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + while (true) { + val lop = client.lop + + if (lop == null) { + synchronized(client.lock) { + if (client.lop == lop) { + return block() + } + + // At this point we know LOP changed since last check, retry because we need to + // lock on LOP first. + } + } else { + synchronized(lop.lock) { + synchronized(client.lock) { + if (client.lop == lop) { + return block() + } + + // At this point we know LOP changed since last check, we're holding the + // wrong LOP lock. Retry because we need to lock on LOP first. + } + } + } + } + } +} + +@OptIn(Internal::class) +class Client( + private val logger: KLogger, + account: Account, + private val password: String, +) { + /** + * All operations on this object must synchronize on this lock. + */ + @Internal + val lock = Any() + private var _account: Account = account + private var _playing: PlayingAccount? = null + private var _lop: LobbyOrParty? = null + private var _id: Byte = -1 + + /** Non-null when logged in. */ + private var sendMessage: ((Message) -> Unit)? = null + + val account: Account get() = synchronized(lock) { _account } + val playing: PlayingAccount? get() = synchronized(lock) { _playing } + val lop: LobbyOrParty? get() = synchronized(lock) { _lop } + + /** + * LOP-specific ID. -1 when not in a LOP. + */ + val id: Byte get() = synchronized(lock) { _id } + + init { + require(password.length <= 16) + } + + fun setPlaying(char: Character, blockId: Int) { + synchronized(lock) { + require(sendMessage != null) { "Trying to set a logged out account to playing." } + + _playing = PlayingAccount(account, char, blockId) + } + } + + fun sendMessage(message: Message) { + val snd = synchronized(lock) { sendMessage } + // Do blocking call outside synchronized block. + snd?.invoke(message) + } + + @Internal + fun logIn(password: String, sendMessage: (Message) -> Unit): AuthResult = + synchronized(lock) { + if (password != this.password) { + AuthResult.BadPassword + } else if (this.sendMessage != null) { + AuthResult.AlreadyLoggedIn + } else { + this.sendMessage = sendMessage + AuthResult.Ok(this) + } + } + + @Internal + fun logOut() { + synchronized(lock) { + if (sendMessage == null) { + logger.warn { + """Trying to log out account ${account.id} "${account.name}" while it wasn't logged in.""" + } + } + + sendMessage = null + _playing = null + _lop = null + } + } + + @Internal + fun setLop(lop: LobbyOrParty?, id: Byte) { + synchronized(lock) { + _id = id + _lop = lop + } + } +} + +@OptIn(Internal::class) +sealed class LobbyOrParty(val id: Int, val blockId: Int, private val maxClients: Int) { + private var clientCount = 0 + + /** + * All operations on this object must synchronize on this lock. All exposed data must be deeply + * immutable and represent a consistent snapshot of the object's state at the time of retrieval. + */ + @Internal + val lock = Any() + + private var _clients: MutableList = MutableList(maxClients) { null } + private var _leaderId: Byte = -1 + + val isEmpty: Boolean get() = synchronized(lock) { clientCount == 0 } + val isFull: Boolean get() = synchronized(lock) { clientCount >= maxClients } + + /** -1 If LOP has no clients. */ + val leaderId: Byte get() = synchronized(lock) { _leaderId } + + fun getClients(): List = synchronized(lock) { _clients.filterNotNull() } + + fun broadcastMessage(message: Message, exclude: Client?) { + val clients = mutableListOf() + + synchronized(lock) { + for (client in _clients) { + if (client != null && client != exclude) { + clients.add(client) + } + } + } + + // Do blocking calls outside of synchronized block. + for (client in clients) { + client.sendMessage(message) + } + } + + /** + * Returns the ID of the client within this lobby when the client can be added, -1 otherwise. + */ + @Internal + fun addClient(client: Client): Byte { + synchronized(lock) { + val iter = _clients.listIterator() + + while (iter.hasNext()) { + val id = iter.nextIndex().toByte() + + if (iter.next() == null) { + iter.set(client) + + if (clientCount == 0) { + _leaderId = id + } + + clientCount++ + return id + } + } + } + + return -1 + } + + @Internal + fun removeClient(client: Client) { + synchronized(lock) { + // Find the client's ID ourselves, so we don't need to lock on client. This way we also + // don't have to trust client. + val id = _clients.indexOf(client) + + if (id != -1) { + _clients[id] = null + clientCount-- + + if (clientCount == 0) { + _leaderId = -1 + } + + if (id == _leaderId.toInt()) { + _leaderId = _clients.firstNotNullOfOrNull { it }?.id ?: -1 + } + } + } + } +} + +class Lobby(id: Int, blockId: Int) : LobbyOrParty(id, blockId, MAX_ACCOUNTS_PER_LOBBY) + +@OptIn(Internal::class) +class Party(id: Int, blockId: Int, details: PartySettings) : + LobbyOrParty(id, blockId, MAX_ACCOUNTS_PER_PARTY) { + + private var _details: PartySettings = details + + val details: PartySettings get() = synchronized(lock) { _details } +} + +class Account( + val id: Long, + val name: String, + val guildCardNo: Int, + val teamId: Int, + val characters: List, +) { + init { + require(name.length <= 16) + } + + override fun toString(): String = "Account[id=$id,name=$name]" +} + +class PlayingAccount( + val account: Account, + val char: Character, + val blockId: Int, +) + +class Character( + val id: Long, + val accountId: Long, + val name: String, + val sectionId: SectionId, + val exp: Int, + val level: Int, +) { + init { + require(name.length <= 16) + require(exp >= 0) + require(level in 1..200) + } +} + +enum class SectionId { + Viridia, + Greenill, + Skyly, + Bluefull, + Purplenum, + Pinkal, + Redria, + Oran, + Yellowboze, + Whitill, +} + +class PartySettings( + val name: String, + val password: String, + val difficulty: Difficulty, + val episode: Episode, + val mode: Mode, +) + +enum class Difficulty { + Normal, Hard, VHard, Ultimate +} + +enum class Mode { + Normal, Battle, Challenge, Solo +} + +sealed class AuthResult { + class Ok(val client: Client) : AuthResult() + object BadPassword : AuthResult() + object AlreadyLoggedIn : AuthResult() +} + +sealed class CreateAndJoinPartyResult { + class Ok(val party: Party) : CreateAndJoinPartyResult() + object NameInUse : CreateAndJoinPartyResult() + object AlreadyInParty : CreateAndJoinPartyResult() +} 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 cc14003e..f5cbb973 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/BbMessages.kt @@ -36,7 +36,9 @@ object BbMessageDescriptor : MessageDescriptor { 0x001D -> BbMessage.Ping(buffer) 0x0060 -> BbMessage.Broadcast(buffer) 0x0061 -> BbMessage.CharData(buffer) + 0x0064 -> BbMessage.JoinParty(buffer) 0x0067 -> BbMessage.JoinLobby(buffer) + 0x0068 -> BbMessage.JoinedLobby(buffer) 0x0083 -> BbMessage.LobbyList(buffer) 0x0093 -> BbMessage.Authenticate(buffer) 0x0095 -> BbMessage.GetCharData(buffer) @@ -167,7 +169,7 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ // 0x001D class Ping(buffer: Buffer) : BbMessage(buffer) { - constructor() : this(buf(code = 0x001D)) + constructor() : this(buf(0x001D)) } // 0x0060 @@ -193,8 +195,51 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ // 0x0061 class CharData(buffer: Buffer) : BbMessage(buffer) - // 0x0067 - class JoinLobby(buffer: Buffer) : BbMessage(buffer) { + // 0x0064 + class JoinParty(buffer: Buffer) : BbMessage(buffer) { + constructor( + players: List, + clientId: Byte, + leaderId: Byte, + difficulty: Byte, + battleMode: Boolean, + event: Byte, + sectionId: Byte, + challengeMode: Boolean, + prngSeed: Int, + episode: Byte, + soloMode: Boolean, + ) : this( + buf(0x0064, 416, flags = players.size) { + require(players.size <= 4) + + repeat(32) { writeInt(0) } // Map layout + + for (player in players) { + player.write(this) + } + + // Empty player slots. + repeat((4 - players.size) * (LobbyPlayer.SIZE / 4)) { writeInt(0) } + + writeByte(clientId) + writeByte(leaderId) + writeByte(1) // Unknown + writeByte(difficulty) + writeByte(if (battleMode) 1 else 0) + writeByte(event) + writeByte(sectionId) + writeByte(if (challengeMode) 1 else 0) + writeInt(prngSeed) + writeByte(episode) + writeByte(1) // Unknown + writeByte(if (soloMode) 1 else 0) + writeByte(0) // Unused + } + ) + } + + abstract class AbstractJoinLobby(buffer: Buffer) : BbMessage(buffer) { val playerCount: Int get() = flags var clientId: UByte get() = uByte(0) @@ -209,39 +254,93 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ get() = uShort(4) set(value) = setUShort(4, value) + override fun toString(): String = + messageString( + "playerCount" to playerCount, + "clientId" to clientId, + "leaderId" to leaderId, + "lobbyNo" to lobbyNo, + "blockNo" to blockNo, + ) + + companion object { + @JvmStatic + protected fun joinLobbyBuf( + code: Int, + clientId: Byte, + leaderId: Byte, + disableUdp: Boolean, + lobbyNo: UByte, + blockNo: UShort, + event: UShort, + players: List, + ): Buffer = + buf(code, 12 + players.size * (LobbyPlayer.SIZE + 1244), flags = players.size) { + writeByte(clientId) + writeByte(leaderId) + writeByte(if (disableUdp) 1 else 0) + writeUByte(lobbyNo) + writeUShort(blockNo) + writeUShort(event) + writeInt(0) // Unused. + + for (player in players) { + player.write(this) + repeat(311) { writeInt(0) } // Inventory and character data. + } + } + } + } + + // 0x0067 + class JoinLobby(buffer: Buffer) : AbstractJoinLobby(buffer) { constructor( - clientId: UByte, - leaderId: UByte, + clientId: Byte, + leaderId: Byte, disableUdp: Boolean, lobbyNo: UByte, blockNo: UShort, event: UShort, players: List, ) : this( - buf(0x0067, 12 + players.size * 1312, flags = players.size) { - writeUByte(clientId) - writeUByte(leaderId) - writeByte(if (disableUdp) 1 else 0) - writeUByte(lobbyNo) - writeUShort(blockNo) - writeUShort(event) - writeInt(0) // Unused. + joinLobbyBuf( + 0x0067, + clientId, + leaderId, + disableUdp, + lobbyNo, + blockNo, + event, + players, + ) + ) + } - for (player in players) { - writeInt(player.playerTag) - writeInt(player.guildCardNo) - repeat(5) { writeInt(0) } // Unknown. - writeInt(player.clientId) - writeStringUtf16(player.charName, 32) - writeInt(0) // Unknown. - repeat(311) { writeInt(0) } - } - } + // 0x0068 + class JoinedLobby(buffer: Buffer) : AbstractJoinLobby(buffer) { + constructor( + clientId: Byte, + leaderId: Byte, + disableUdp: Boolean, + lobbyNo: UByte, + blockNo: UShort, + event: UShort, + player: LobbyPlayer, + ) : this( + joinLobbyBuf( + 0x0068, + clientId, + leaderId, + disableUdp, + lobbyNo, + blockNo, + event, + listOf(player), + ) ) override fun toString(): String = messageString( - "playerCount" to playerCount, "clientId" to clientId, "leaderId" to leaderId, "lobbyNo" to lobbyNo, @@ -251,11 +350,11 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ // 0x0083 class LobbyList(buffer: Buffer) : BbMessage(buffer) { - constructor() : this( + constructor(lobbyIds: List) : this( buf(0x0083, 192) { - repeat(15) { + for (lobbyId in lobbyIds) { writeInt(MenuType.Lobby.toInt()) - writeInt(it + 1) // Item ID. + writeInt(lobbyId) // Item ID. writeInt(0) // Padding. } // 12 zero bytes of padding. @@ -277,6 +376,16 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ val magic: Int get() = int(132) // Should be 0xDEADBEEF val charSlot: Int get() = byte(136).toInt() val charSelected: Boolean get() = byte(137).toInt() != 0 + + override fun toString(): String = + messageString( + "guildCardNo" to guildCardNo, + "version" to version, + "teamId" to teamId, + "username" to username, + "charSlot" to charSlot, + "charSelected" to charSelected, + ) } // 0x0095 @@ -305,7 +414,39 @@ sealed class BbMessage(override val buffer: Buffer) : AbstractMessage(BB_HEADER_ } // 0x00C1 - class CreateParty(buffer: Buffer) : BbMessage(buffer) + class CreateParty(buffer: Buffer) : BbMessage(buffer) { + var name: String + get() = stringUtf16(8, 32) + set(value) = setStringUtf16(8, value, 32) + var password: String + get() = stringUtf16(40, 32) + set(value) = setStringUtf16(40, value, 32) + var difficulty: Byte + get() = byte(72) + set(value) = setByte(72, value) + var battleMode: Boolean + get() = byte(73).toInt() != 0 + set(value) = setByte(73, if (value) 1 else 0) + var challengeMode: Boolean + get() = byte(74).toInt() != 0 + set(value) = setByte(74, if (value) 1 else 0) + var episode: Byte + get() = byte(75) + set(value) = setByte(75, value) + var soloMode: Boolean + get() = byte(76).toInt() != 0 + set(value) = setByte(76, if (value) 1 else 0) + + override fun toString(): String = + messageString( + "name" to name, + "difficulty" to difficulty, + "battleMode" to battleMode, + "challengeMode" to challengeMode, + "episode" to episode, + "soloMode" to soloMode, + ) + } // 0x01DC class GuildCardHeader(buffer: Buffer) : BbMessage(buffer) { @@ -735,9 +876,49 @@ class PsoCharData( } } +class PlayerHeader( + val playerTag: Int, + val guildCardNo: Int, + val clientId: UByte, + val charName: String, +) { + fun write(cursor: WritableCursor) { + with(cursor) { + writeInt(playerTag) + writeInt(guildCardNo) + repeat(5) { writeInt(0) } // Unknown. + writeUByte(clientId) + repeat(3) { writeByte(0) } // Unknown. + writeStringUtf16(charName, 32) + writeInt(0) // Unknown. + repeat(311) { writeInt(0) } + } + } + + companion object { + const val SIZE: Int = 1312 + } +} + class LobbyPlayer( val playerTag: Int, val guildCardNo: Int, - val clientId: Int, + val clientId: Byte, val charName: String, -) +) { + fun write(cursor: WritableCursor) { + with(cursor) { + writeInt(playerTag) + writeInt(guildCardNo) + repeat(5) { writeInt(0) } // Unknown. + writeByte(clientId) + repeat(3) { writeByte(0) } // Unknown. + writeStringUtf16(charName, 32) + writeInt(0) // Unknown. + } + } + + companion object { + const val SIZE: Int = 68 + } +} diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/Messages.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/Messages.kt index 0d7a54f1..aa606e09 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/Messages.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/messages/Messages.kt @@ -72,6 +72,8 @@ abstract class AbstractMessage(override val headerSize: Int) : Message { protected fun byteArray(offset: Int, size: Int) = ByteArray(size) { byte(offset + it) } protected fun stringAscii(offset: Int, maxByteLength: Int) = buffer.getStringAscii(headerSize + offset, maxByteLength, nullTerminated = true) + protected fun stringUtf16(offset: Int, maxByteLength: Int) = + buffer.getStringUtf16(headerSize + offset, maxByteLength, nullTerminated = true) protected fun setUByte(offset: Int, value: UByte) { buffer.setUByte(headerSize + offset, value) @@ -103,6 +105,10 @@ abstract class AbstractMessage(override val headerSize: Int) : Message { buffer.setStringAscii(headerSize + offset, str, byteLength) } + protected fun setStringUtf16(offset: Int, str: String, byteLength: Int) { + buffer.setStringUtf16(headerSize + offset, str, byteLength) + } + protected fun messageString(vararg props: Pair): String = messageString(code, size, flags, this::class.simpleName, *props) } 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 7e9d97d9..bd0f19d0 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/AccountServer.kt @@ -4,16 +4,16 @@ import world.phantasmal.core.math.clamp import world.phantasmal.psolib.Endianness import world.phantasmal.psolib.buffer.Buffer import world.phantasmal.psolib.cursor.cursor -import world.phantasmal.psoserv.data.AccountData -import world.phantasmal.psoserv.data.AccountStore -import world.phantasmal.psoserv.data.LogInResult +import world.phantasmal.psoserv.data.Client +import world.phantasmal.psoserv.data.AuthResult +import world.phantasmal.psoserv.data.Store import world.phantasmal.psoserv.encryption.BbCipher import world.phantasmal.psoserv.encryption.Cipher import world.phantasmal.psoserv.messages.* import world.phantasmal.psoserv.utils.crc32Checksum class AccountServer( - private val accountStore: AccountStore, + private val store: Store, bindPair: Inet4Pair, private val ships: List, ) : GameServer("account", bindPair) { @@ -27,7 +27,7 @@ class AccountServer( serverCipher: Cipher, clientCipher: Cipher, ): ClientReceiver = object : ClientReceiver { - private var accountData: AccountData? = null + private var client: Client? = null private val guildCardBuffer = Buffer.withSize(54672) private var fileChunkNo = 0 private var charSlot: Int = 0 @@ -35,15 +35,19 @@ class AccountServer( override fun process(message: BbMessage): Boolean = when (message) { is BbMessage.Authenticate -> { - val accountData = accountStore.getAccountData(message.username, message.password) - this.accountData = accountData + val result = store.authenticate( + message.username, + message.password, + ctx::send, + ) - when (accountData.logIn(message.password)) { - LogInResult.Ok -> { + when (result) { + is AuthResult.Ok -> { + client = result.client charSlot = message.charSlot charSelected = message.charSelected - val account = accountData.account + val account = result.client.account ctx.send( BbMessage.AuthData( AuthStatus.Success, @@ -60,7 +64,7 @@ class AccountServer( ctx.send(BbMessage.ShipList(ships.map { it.uiName })) } } - LogInResult.BadPassword -> { + AuthResult.BadPassword -> { ctx.send( BbMessage.AuthData( AuthStatus.Nonexistent, @@ -71,7 +75,7 @@ class AccountServer( ) ) } - LogInResult.AlreadyLoggedIn -> { + AuthResult.AlreadyLoggedIn -> { ctx.send( BbMessage.AuthData( AuthStatus.Error, @@ -88,7 +92,7 @@ class AccountServer( } is BbMessage.GetAccount -> { - accountData?.account?.let { + client?.account?.let { ctx.send(BbMessage.Account(it.guildCardNo, it.teamId)) } @@ -96,7 +100,7 @@ class AccountServer( } is BbMessage.CharSelect -> { - val account = accountData?.account + val account = client?.account if (account != null && message.slot in account.characters.indices) { if (message.selected) { @@ -252,9 +256,9 @@ class AccountServer( private fun logOut() { try { - accountData?.let(AccountData::logOut) + client?.let(store::logOut) } finally { - accountData = null + client = null } } } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/BlockServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/BlockServer.kt index d65af97c..84a91853 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/BlockServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/BlockServer.kt @@ -1,14 +1,13 @@ package world.phantasmal.psoserv.servers -import world.phantasmal.psoserv.data.AccountData -import world.phantasmal.psoserv.data.AccountStore -import world.phantasmal.psoserv.data.LogInResult +import world.phantasmal.psolib.Episode +import world.phantasmal.psoserv.data.* import world.phantasmal.psoserv.encryption.BbCipher import world.phantasmal.psoserv.encryption.Cipher import world.phantasmal.psoserv.messages.* class BlockServer( - private val accountStore: AccountStore, + private val store: Store, name: String, bindPair: Inet4Pair, private val blockId: Int, @@ -23,18 +22,67 @@ class BlockServer( serverCipher: Cipher, clientCipher: Cipher, ): ClientReceiver = object : ClientReceiver { - private var accountData: AccountData? = null + private var client: Client? = null - override fun process(message: BbMessage): Boolean = when (message) { - is BbMessage.Authenticate -> { - val accountData = accountStore.getAccountData(message.username, message.password) - this.accountData = accountData + override fun process(message: BbMessage): Boolean { + when (message) { + is BbMessage.Authenticate -> { + val result = store.authenticate( + message.username, + message.password, + ctx::send, + ) - when (accountData.logIn(message.password)) { - LogInResult.Ok -> { - val char = accountData.account.characters.getOrNull(message.charSlot) + when (result) { + is AuthResult.Ok -> { + client = result.client - if (char == null) { + val account = result.client.account + val char = account.characters.getOrNull(message.charSlot) + + if (char == null) { + ctx.send( + BbMessage.AuthData( + AuthStatus.Nonexistent, + message.guildCardNo, + message.teamId, + message.charSlot, + message.charSelected, + ) + ) + } else { + result.client.setPlaying(char, blockId) + ctx.send( + BbMessage.AuthData( + AuthStatus.Success, + account.guildCardNo, + account.teamId, + message.charSlot, + message.charSelected, + ) + ) + + val lobbies = store.getLobbies(blockId) + ctx.send(BbMessage.LobbyList(lobbies.map { it.id })) + + ctx.send( + BbMessage.FullCharacterData( + // TODO: Fill in char data correctly. + PsoCharData( + hp = 0, + level = char.level - 1, + exp = char.exp, + sectionId = char.sectionId.ordinal.toByte(), + charClass = 0, + name = char.name, + ), + ) + ) + + ctx.send(BbMessage.GetCharData()) + } + } + AuthResult.BadPassword -> { ctx.send( BbMessage.AuthData( AuthStatus.Nonexistent, @@ -44,96 +92,178 @@ class BlockServer( message.charSelected, ) ) - } else { - accountData.setPlaying(char, blockId) - val account = accountData.account + } + AuthResult.AlreadyLoggedIn -> { ctx.send( BbMessage.AuthData( - AuthStatus.Success, - account.guildCardNo, - account.teamId, + AuthStatus.Error, + message.guildCardNo, + message.teamId, message.charSlot, message.charSelected, ) ) - - ctx.send(BbMessage.LobbyList()) - ctx.send( - BbMessage.FullCharacterData( - PsoCharData( - hp = 0, - level = char.level - 1, - exp = char.exp, - sectionId = char.sectionId.ordinal.toByte(), - charClass = 0, - name = char.name, - ), - ) - ) - ctx.send(BbMessage.GetCharData()) } } - LogInResult.BadPassword -> { - ctx.send( - BbMessage.AuthData( - AuthStatus.Nonexistent, - message.guildCardNo, - message.teamId, - message.charSlot, - message.charSelected, - ) + + return true + } + + is BbMessage.CharData -> { + val client = client + ?: return false + + val lobby = store.joinFirstAvailableLobby(blockId, client) + ?: return false + + val clientId = client.id + + ctx.send( + BbMessage.JoinLobby( + clientId = clientId, + leaderId = 0, // TODO: What should leaderId be in lobbies? + disableUdp = true, + lobbyNo = lobby.id.toUByte(), + blockNo = blockId.toUShort(), + event = 0u, + players = lobby.getClients().mapNotNull { c -> + c.playing?.let { + LobbyPlayer( + playerTag = 0, + guildCardNo = it.account.guildCardNo, + clientId = c.id, + charName = it.char.name, + ) + } + } ) + ) + + // Notify other clients. + client.playing?.let { playingAccount -> + val joinedMessage = BbMessage.JoinedLobby( + clientId = clientId, + leaderId = 0, // TODO: What should leaderId be in lobbies? + disableUdp = true, + lobbyNo = lobby.id.toUByte(), + blockNo = blockId.toUShort(), + event = 0u, + player = LobbyPlayer( + playerTag = 0, + guildCardNo = playingAccount.account.guildCardNo, + clientId = clientId, + charName = playingAccount.char.name, + ), + ) + lobby.broadcastMessage(joinedMessage, exclude = client) } - LogInResult.AlreadyLoggedIn -> { - ctx.send( - BbMessage.AuthData( - AuthStatus.Error, - message.guildCardNo, - message.teamId, - message.charSlot, - message.charSelected, - ) - ) + + return true + } + + is BbMessage.CreateParty -> { + val client = client + ?: return false + val difficulty = when (message.difficulty.toInt()) { + 0 -> Difficulty.Normal + 1 -> Difficulty.Hard + 2 -> Difficulty.VHard + 3 -> Difficulty.Ultimate + else -> return false + } + val episode = when (message.episode.toInt()) { + 1 -> Episode.I + 2 -> Episode.II + 3 -> Episode.IV + else -> return false + } + val mode = when { + message.battleMode -> Mode.Battle + message.challengeMode -> Mode.Challenge + message.soloMode -> Mode.Solo + else -> Mode.Normal + } + + val result = store.createAndJoinParty( + blockId, + message.name, + message.password, + difficulty, + episode, + mode, + client, + ) + + when (result) { + is CreateAndJoinPartyResult.Ok -> { + val party = result.party + val details = party.details + + // TODO: Send lobby leave message to all clients. + + ctx.send(BbMessage.JoinParty( + players = party.getClients().mapNotNull { c -> + c.playing?.let { + LobbyPlayer( + playerTag = 0, + guildCardNo = it.account.guildCardNo, + clientId = c.id, + charName = it.char.name, + ) + } + }, + clientId = client.id, + leaderId = party.leaderId, + difficulty = when (details.difficulty) { + Difficulty.Normal -> 0 + Difficulty.Hard -> 1 + Difficulty.VHard -> 2 + Difficulty.Ultimate -> 3 + }, + battleMode = details.mode == Mode.Battle, + event = 0, + sectionId = 0, + challengeMode = details.mode == Mode.Challenge, + prngSeed = 0, + episode = when (details.episode) { + Episode.I -> 1 + Episode.II -> 2 + Episode.IV -> 3 + }, + soloMode = details.mode == Mode.Solo, + )) + + // TODO: Send player join message to other clients. + + return true + } + is CreateAndJoinPartyResult.NameInUse -> { + // TODO: Just send message instead of disconnecting. + return false + } + is CreateAndJoinPartyResult.AlreadyInParty -> { + logger.warn { + "${client.account} tried to create a party while in a party." + } + return true + } } } - true + is BbMessage.Broadcast -> { + // TODO: Verify broadcast messages. + client?.lop?.broadcastMessage(message, client) + return true + } + + is BbMessage.Disconnect -> { + // Log out and disconnect. + logOut() + return false + } + + else -> return ctx.unexpectedMessage(message) } - - is BbMessage.CharData -> { - ctx.send( - BbMessage.JoinLobby( - clientId = 0u, - leaderId = 0u, - disableUdp = true, - lobbyNo = 0u, - blockNo = blockId.toUShort(), - event = 0u, - players = accountStore.getPlayingAccountsForBlock(blockId).map { - LobbyPlayer( - playerTag = 0, - guildCardNo = it.account.guildCardNo, - clientId = 0, - charName = it.char.name, - ) - } - ) - ) - - true - } - - is BbMessage.CreateParty -> { - true - } - - is BbMessage.Disconnect -> { - // Log out and disconnect. - logOut() - false - } - - else -> ctx.unexpectedMessage(message) } override fun connectionClosed() { @@ -142,9 +272,9 @@ class BlockServer( private fun logOut() { try { - accountData?.let(AccountData::logOut) + client?.let(store::logOut) } finally { - accountData = null + client = null } } } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/GameServer.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/GameServer.kt index 19b48bb2..187cd3cd 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/GameServer.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/GameServer.kt @@ -40,7 +40,7 @@ abstract class GameServer( private val logger: KLogger, private val handler: SocketHandler, ) { - fun send(message: MessageType, encrypt: Boolean = true) { + fun send(message: Message, encrypt: Boolean = true) { handler.sendMessage(message, encrypt) } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/Server.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/Server.kt index 348cce2c..5f9890ae 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/Server.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/Server.kt @@ -49,6 +49,7 @@ abstract class Server( while (running) { try { val clientSocket = bindSocket.accept() + // TODO: Limit number of connected clients. logger.info { "New client connection from ${clientSocket.inetAddress}:${clientSocket.port}." } diff --git a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/SocketHandler.kt b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/SocketHandler.kt index 3dd0501e..6d93983c 100644 --- a/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/SocketHandler.kt +++ b/psoserv/src/main/kotlin/world/phantasmal/psoserv/servers/SocketHandler.kt @@ -140,8 +140,8 @@ abstract class SocketHandler( break@readLoop } } - } catch (e: Throwable) { - logger.error(e) { "Error while processing message." } + } catch (e: Exception) { + logger.error(e) { "Exception while processing message." } } offset += encryptedSize @@ -231,19 +231,14 @@ abstract class SocketHandler( socket.close() } - fun sendMessage(message: MessageType, encrypt: Boolean) { + fun sendMessage(message: Message, encrypt: Boolean) { logger.trace { "Sending $message${if (encrypt) "" else " (unencrypted)"}." } - if (message.buffer.size != message.size) { - logger.warn { - "Message size of $message is ${message.size}B, but wrote ${message.buffer.size} bytes." - } - } - val cipher = writeEncryptCipher val buffer: Buffer + val expectedMaxSize: Int if (encrypt) { checkNotNull(cipher) @@ -253,8 +248,17 @@ abstract class SocketHandler( size = alignToWidth(initialSize, cipher.blockSize) ) cipher.encrypt(buffer) + expectedMaxSize = alignToWidth(message.size, cipher.blockSize) } else { buffer = message.buffer + expectedMaxSize = message.size + } + + // Message buffer can be padded for encryption in advance. + if (message.buffer.size !in message.size..expectedMaxSize) { + logger.warn { + "Message size of $message is ${message.size}B, but wrote ${message.buffer.size} bytes." + } } socket.write(buffer, 0, buffer.size)