blurite / rsprot

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

PlayerInfo #2

Closed Z-Kris closed 5 months ago

Z-Kris commented 6 months ago

Player Info

Due to the complex nature of the player info packet, the library will need to hold most of the state of everyone. It is not feasible to acquire information dynamically as that induces overhead, nor is it viable to build data classes to pass onto the library, as this would be way too costly.

Player Info Repository

Player info repository will provide a way of creating player info instances. These instances will have access to the repository itself as well, in order to look up information of everyone else, as it is a crucial part of player info packet. With this said, player information is actually fairly stand-alone. The only piece of information it will need provided from the server is the coord of everyone, anything else it can handle within itself.

Below is an expected implementation of the repository:

PlayerInfoRepository Snippet ```kt class PlayerInfoRepository(capacity: Int = DEFAULT_CAPACITY) { private val infos: Array = arrayOfNulls(capacity) private val queue: ReferenceQueue = ReferenceQueue() fun getOrNull(idx: Int): PlayerInfo? { return infos.getOrNull(idx) } fun capacity(): Int { return infos.size } fun alloc(idx: Int): PlayerInfo { require(idx in infos.indices) { "Index out of boundaries: $idx, ${infos.indices}" } val old = infos[idx] require(old == null) { "Overriding existing player info: $idx" } val cached = queue.poll()?.get() if (cached != null) { cached.reset() infos[idx] = cached return cached } val info = PlayerInfo(this) infos[idx] = info return info } fun dealloc(idx: Int) { val info = infos[idx] ?: return infos[idx] = null info.resetReferences() val reference = SoftReference(info, queue) reference.enqueue() } private companion object { const val DEFAULT_CAPACITY = 2048 } } ```

[!TIP] By default, the player info repository would utilize a reference queue to avoid re-allocating these rather-heavy player info objects. If developers wish, they will be able to opt out of this pooling,

Player Info Implementation

Below is a rough data structure of the player info packet, holding all the state necessary for the protocol to function correctly. Note that this is only a draft to better envision what is needed from the library. The end product may be vastly different.

Avatar Snippet Avatar class will hold properties about the given local player alone. ```kt internal data class Avatar( /** * 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. */ private 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. */ private 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 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. */ private var currentCoord: CoordGrid = CoordGrid.INVALID, /** * 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. */ private var lastCoord: CoordGrid = CoordGrid.INVALID, ) { /** * Resets all the properties to their default state. * This allows the given [Avatar] to be reused in the future for another player. */ fun reset() { preferredResizeRange = DEFAULT_RESIZE_RANGE resizeRange = preferredResizeRange resizeCounter = DEFAULT_RESIZE_INTERVAL currentCoord = CoordGrid.INVALID lastCoord = CoordGrid.INVALID } internal fun updateCoord(coordGrid: CoordGrid) { this.currentCoord = coordGrid } internal fun postUpdate() { this.lastCoord = this.currentCoord } private companion object { private const val DEFAULT_RESIZE_RANGE = 15 private const val DEFAULT_RESIZE_INTERVAL = 10 } } ```
GlobalAvatarGroup Snippet GlobalAvatarGroup will hold information about every avatar in the game, including the local player. ```kt internal class GlobalAvatarGroup(capacity: Int) { /** * Stationary player flags are used to mark players inactive if they were skipped during processing. * By utilizing this strategy, the client's code is split into four loops instead of the usual two. * For both the low resolution players and high resolution players, it will iterate active * and inactive players separately. This primarily boils down to probabilities: * If the processed player was inactive in the previous cycle, they are likely inactive * in this cycle as well. Due to this, the protocol is able to "batch" players together * more efficiently. */ private val stationaryPlayers: ByteArray = ByteArray(capacity) /** * 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(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: BitSet = BitSet(capacity) /** * 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(capacity) /** * The number of players in high resolution according to the protocol. */ private var highResolutionCount: Int = 0 /** * Player coordinates are tracked in separate variables. In order to get the full coordinate, * the two values must be merged. In recent OSRS, the [mapSectors] will only track which quadrant * of the map the given player is in, where each quadrant is 8192x8192 tiles in size. * In older versions of OSRS, as well as higher revisions during the pre-eoc era, * the [mapSectors] would store the mapsquare (64x64 tiles) in which the player is. * OSRS changed this during Deadman Mode to avoid players observing the number of players * in nearby mapsquares. */ private val mapSectors: IntArray = IntArray(capacity) /** * 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 [extendedInfoRepository] is also responsible for caching the non-temporary blocks, * such as appearance and move speed. */ private val extendedInfoRepository = ExtendedInfoRepository(capacity) internal fun reset() { stationaryPlayers.fill(0) lowResolutionIndices.fill(0) lowResolutionCount = 0 highResolutionIndices.fill(0) highResolutionCount = 0 mapSectors.fill(0) extendedInfoRepository.reset() } internal fun clearTransientExtendedInformation() { extendedInfoRepository.clearTransientExtendedInformation() } private companion object { private const val CURRENT_CYCLE_INACTIVE = 0x1 private const val NEXT_CYCLE_INACTIVE = 0x2 } } ```
PlayerInfo Snippet PlayerInfo will hold references to the local avatar and the global avatar group of this player. This class will directly be responsible for managing extended info updates too, e.g. flagging spotanims will be done through a pre-defined function within this class. ```kt class PlayerInfo internal constructor( private val repository: PlayerInfoRepository, ) { /** * The [avatar] represents properties of our local player. */ private val avatar: Avatar = Avatar() /** * The [globalAvatarGroup] represents information about every player in the world. */ private val globalAvatarGroup: GlobalAvatarGroup = GlobalAvatarGroup(repository.capacity()) /** * Reset all the properties of this [PlayerInfo] class. * This will allow for the [PlayerInfo] to be pooled and re-used later on, * without needing to deallocate and reallocate it. */ internal fun reset() { avatar.reset() } /** * Synchronize the necessary properties for this player. * Note: this must be done for all players before calling [prepareBitcodes]. */ fun synchronize( x: Int, z: Int, level: Int, ) { this.avatar.updateCoord(CoordGrid(x, z, level)) this.globalAvatarGroup.clearTransientExtendedInformation() } /** * Precalculates all the bitcodes for this player, for both low-resolution and high-resolution updates. * This function will be thread-safe relative to other players and can be calculated concurrently for all players. */ internal fun prepareBitcodes(): Unit = TODO() /** * Precalculates all the extended information blocks that are observer-independent. * Any extended information blocks which rely on the observer will be calculated on-demand during [update]. * This function will be thread-safe relative to other players and can be calculated concurrently for all players. */ internal fun prepareExtendedInformation(): Unit = TODO() /** * 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 update(): Unit = TODO() /** * Reset any temporary properties from this cycle. * Any extended information which doesn't require caching would also be cleared out at this stage. */ fun postUpdate() { this.avatar.postUpdate() } } ```

The player info class would be constructed per player in the given world. Extended info buffers will be computed once where applicable (e.g. appearance, which is observer-independent) when an update occurs. Observer-dependent extended info (e.g. hitmarks) will be calculated on-demand by the observer. Some extended info blocks will be subject to temporal caching, such as appearance and move speed. This will be done by tracking the game cycle when the last update occurred. Every observer will additionally keep track of their latest version - if the game cycles align, the extended info block is omitted for that observer, as it is already equivalent to that tracked by the client. Bitcodes will be generated for all players at the start for both low resolution and high resolution. This is to remain in line with the patent, and to reduce the number of individual bit writes. Instead of writing 2,000 x 2,000 (4 million) bitcodes individually over many bit writes, the 2,000 bitcodes of both variants will be pre-computed at the start, resulting in two short slices for each player. Every observer will then pick the correct bitcode for their given needs and copy the slice of bits over to their buffer in a more efficient manner than one would get by individually writing the respective bits.

Extended Info

A large portion of player info packet is made up by the extended info block, commonly referred to as "masks" in private servers. Extended info will need to meet several criteria:

Below is an example of the class with which extended info properties would be assigned:

[!NOTE] Appearance in the AvatarExtendedInfo snippet below is in an outdated format that is likely scrapped. Discussion on appearance can be found further below.

AvatarExtendedInfo Snippet

AvatarExtendedInfo class will be used as an API to set the state of the given player. All extended info updates besides appearance are done through functions with arguments for all the properties. Appearance is done using an internal mutable class as there are way too many properties to pass into a function, and generating new instances of appearance whenever a property changes will result in heavy garbage creation.

When an extended info block is updated, the respective bitflag will automatically be enabled. These bitflags will directly correspond to the ones utilized by the client. The below snippet has stubs for logic right now, each extended info block itself will have its own dedicated instance with properties corresponding to it (e.g. cacheable, temporary, observer-dependent).

class AvatarExtendedInfo {
    @Volatile
    @JvmSynthetic
    @PublishedApi
    internal var flags: Int = 0

    fun setMoveSpeed(value: Int) {
        flags = flags or MOVE_SPEED
    }

    fun setTempMoveSpeed(value: Int) {
        flags = flags or TEMP_MOVE_SPEED
    }

    fun setSequence(
        id: Int,
        delay: Int,
    ) {
        flags = flags or SEQUENCE
    }

    fun setFacePathingEntity(index: Int) {
        flags = flags or FACE_PATHINGENTITY
    }

    fun setFaceCoord(angle: Int) {
        flags = flags or FACE_COORD
    }

    fun setNameExtras(
        beforeName: String?,
        afterName: String?,
        afterCombatLevel: String?,
    ) {
        flags = flags or NAME_EXTRAS
    }

    fun setSay(text: String) {
        flags = flags or SAY
    }

    fun setChat(
        effects: Int,
        modicon: Int,
        autotyper: Boolean,
        text: String,
        pattern: ByteArray?,
    ) {
        flags = flags or CHAT
    }

    fun setExactMove(
        deltaX1: Int,
        deltaY1: Int,
        delay1: Int,
        deltaX2: Int,
        deltaY2: Int,
        delay2: Int,
        direction: Int,
    ) {
        flags = flags or EXACT_MOVE
    }

    fun setSpotAnim(
        slot: Int,
        id: Int,
        delay: Int,
        height: Int,
    ) {
        flags = flags or SPOTANIM
    }

    fun addHitMark(
        sourceIndex: Int,
        selfType: Int,
        otherType: Int = selfType,
        value: Int,
        delay: Int = 0,
    ) {
        flags = flags or HITS
    }

    fun addSoakedHitMark(
        sourceIndex: Int,
        selfType: Int,
        otherType: Int = selfType,
        value: Int,
        selfSoakType: Int,
        otherSoakType: Int = selfSoakType,
        soakValue: Int,
        delay: Int = 0,
    ) {
        flags = flags or HITS
    }

    fun addHeadBar(
        id: Int,
        fill: Int,
        nextFill: Int = fill,
        startTime: Int = 0,
        endTime: Int = 0,
    ) {
        flags = flags or HITS
    }

    fun removeHeadBar(id: Int) {
        addHeadBar(
            id,
            fill = 0,
            endTime = 0x7FFF,
        )
    }

    fun tinting(
        startTime: Int,
        endTime: Int,
        hue: Int,
        saturation: Int,
        luminance: Int,
        opacity: Int,
        local: Boolean = false,
    ) {
        flags = flags or TINTING
    }

    @JvmSynthetic
    @PublishedApi
    internal inline fun modifyAppearanceInline(block: Appearance.() -> Unit) {
        flags = flags or APPEARANCE
    }

    fun modifyAppearance(consumer: AppearanceConsumer) {
        flags = flags or APPEARANCE
    }

    @PublishedApi
    internal companion object {
        const val APPEARANCE = 0x1
        const val SEQUENCE = 0x2
        const val EXTENDED_SHORT = 0x4
        const val HITS = 0x8
        const val FACE_PATHINGENTITY = 0x20
        const val SAY = 0x40
        const val FACE_COORD = 0x80
        const val TINTING = 0x100
        const val MOVE_SPEED = 0x200
        const val CHAT = 0x800
        const val EXACT_MOVE = 0x1000
        const val NAME_EXTRAS = 0x2000
        const val EXTENDED_MEDIUM = 0x4000
        const val TEMP_MOVE_SPEED = 0x8000
        const val SPOTANIM = 0x10000
    }
}

Appearance

One of the more challenging parts about extended info is appearance, as it consists of a lot of smaller properties. This leaves us with a dilemma - should the library be responsible for storing all the appearance information in a way that would be accessible to the server as well, or should the server be responsible for maintaining its own source of truth?

As of revision 220, appearance consists of the following properties:

Because of the extensive set of properties, likely the best course of action is to allow the server to look up any property on-demand. This would mean servers no longer need to maintain their own their own set of data, especially given how expensive these objects can get (the min sum of everything listed above is already 51 bytes).

Below are some bullet points I wish to achieve with appearance: