diff --git a/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt b/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt index ee05bf89f..ff0592db7 100644 --- a/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt +++ b/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt @@ -27,6 +27,7 @@ import net.rsprot.protocol.internal.game.outgoing.info.util.ZoneIndexStorage internal data class BenchmarkInfoProtocolContext( val protocols: InfoProtocols, val npcAvatarFactory: NpcAvatarFactory, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, ) internal fun generateBenchmarkInfoProtocols( @@ -64,17 +65,18 @@ internal fun generateBenchmarkInfoProtocols( npcProtocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) val playerInfoProtocol = @@ -95,6 +97,7 @@ internal fun generateBenchmarkInfoProtocols( worldEntityInfoProtocol, ), npcAvatarFactory, + worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt index 5ddf2da2a..7ff2a084c 100644 --- a/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt +++ b/protocol/osrs-235/osrs-235-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Measurement import org.openjdk.jmh.annotations.Mode import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -27,34 +28,104 @@ import kotlin.random.Random class PlayerInfoBenchmark { private lateinit var protocols: InfoProtocols private lateinit var players: Array + private lateinit var positions: Array private val random: Random = Random(0) + private var tickCycle: Int = 0 + + @Param + private lateinit var scenario: Scenario + + @Param + private lateinit var activity: Activity @Setup fun setup() { - protocols = + val context = generateBenchmarkInfoProtocols( playerProtocolWorker = DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), - ).protocols + ) + protocols = context.protocols players = arrayOfNulls(PROTOCOL_CAPACITY) + positions = arrayOfNulls(PROTOCOL_CAPACITY) + if (scenario == Scenario.MIXED_WORLD_ENTITIES) { + repeat(WORLD_ENTITY_COUNT) { index -> + context.worldEntityAvatarFactory.alloc( + index = index + 1, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = INSTANCE_ZONE_X + index, + southWestZoneZ = INSTANCE_ZONE_Z, + minLevel = 0, + maxLevel = 0, + fineX = (ROOT_X + index * 2) * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = 0, + angle = 0, + ) + } + } for (i in 1.. + PlayerPosition( + x = ROOT_X + random.nextInt(8), + z = ROOT_Z + random.nextInt(8), + buildAreaCenterX = ROOT_X, + buildAreaCenterZ = ROOT_Z, + ) + Scenario.DISTRIBUTED_ROOT -> { + val x = ROOT_X + (index % 50) * 32 + val z = ROOT_Z + (index / 50) * 32 + PlayerPosition(x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + } else { + val worldIndex = (index - ROOT_PLAYER_COUNT - 1) % WORLD_ENTITY_COUNT + PlayerPosition( + x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, + z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, + buildAreaCenterX = ROOT_X + worldIndex * 2, + buildAreaCenterZ = ROOT_Z, + ) + } + } + } + private fun initializeAppearance( player: PlayerInfo, index: Int, @@ -92,17 +163,17 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + fixture.updateTarget(10, near = true) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = true, xOffset = 2) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false, xOffset = 2) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = false) + fixture.updateTarget(11, near = false) + fixture.updateTarget(20, near = true) + fixture.updateTarget(21, near = true) + packets += fixture.tick() + + assertEquals(EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX, packets) + } + + @Test + fun `stationary player membership is rebuilt after world entity allocation removal and index reuse`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X + 7, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 8, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + val coveringWorld = + fixture.allocWorld( + index = 1, + zoneX = (ROOT_X + 8) ushr 3, + activeLevel = 0, + projectedX = ROOT_X + 104, + zoneZ = ROOT_Z ushr 3, + ) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.releaseWorld(coveringWorld) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.allocWorld( + index = 1, + zoneX = INSTANCE_ZONE_X, + activeLevel = 0, + projectedX = ROOT_X + 104, + ) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `parallel observer updates retain baseline packet bytes`() { + repeat(10) { + assertEquals( + EXPECTED_PACKET_HEX, + membershipTransitionPackets(ForkJoinMultiThreadProtocolWorker()), + ) + } + } + + private fun membershipTransitionPackets(playerProtocolWorker: ProtocolWorker? = null): List { + val fixture = PlayerFixture(playerProtocolWorker) + val packets = ArrayList() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + packets += fixture.tick() + + fixture.target.playerInfo.avatar.hidden = true + packets += fixture.tick() + fixture.target.playerInfo.avatar.hidden = false + packets += fixture.tick() + + val coveringWorld = fixture.allocWorld(1, ROOT_X ushr 3, activeLevel = 0, ROOT_X + 4) + packets += fixture.tick() + fixture.releaseWorld(coveringWorld) + packets += fixture.tick() + + fixture.updateTarget(0, ROOT_X + 104, ROOT_Z) + packets += fixture.tick() + + fixture.allocWorld(2, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 4) + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(instanceCoord(0, 1)) + packets += fixture.tick() + fixture.updateTarget(instanceCoord(1, 1)) + packets += fixture.tick() + return packets + } + + @Test + fun `final movement and player index reuse replace cached membership`() { + val fixture = PlayerFixture() + fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(CoordGrid(0, ROOT_X + 1, ROOT_Z)) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `world entity projection movement uses current root coordinate`() { + val fixture = PlayerFixture() + val world = fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + world.updateCoord(0, ROOT_X * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + world.updateCoord(0, (ROOT_X + 104) * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + } + + @Test + fun `unlimited resize range preserves visibility bypass`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(3, ROOT_X + 104, ROOT_Z + 104) + fixture.observer.playerInfo.avatar.forceResizeRange(Int.MAX_VALUE) + fixture.tick() + + assertTrue(fixture.targetIsHighResolution()) + } + + private class PlayerFixture(playerProtocolWorker: ProtocolWorker? = null) { + private val context = + if (playerProtocolWorker == null) { + generateInfoProtocolContext() + } else { + generateInfoProtocolContext(playerProtocolWorker = playerProtocolWorker) + } + private val protocols = context.protocols + val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + var target: Infos + private set + + init { + updateObserver(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + initializeAppearance(target.playerInfo) + } + + fun updateObserver( + level: Int, + x: Int, + z: Int, + ) { + observer.updateRootCoord(level, x, z) + } + + fun updateObserver(coord: CoordGrid) = updateObserver(coord.level, coord.x, coord.z) + + fun updateTarget( + level: Int, + x: Int, + z: Int, + ) { + target.updateRootCoord(level, x, z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun updateTarget(coord: CoordGrid) = updateTarget(coord.level, coord.x, coord.z) + + fun allocWorld( + index: Int, + zoneX: Int, + activeLevel: Int, + projectedX: Int, + zoneZ: Int = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + ): WorldEntityAvatar = + allocWorld(context.worldEntityAvatarFactory, index, zoneX, zoneZ, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(coord) + initializeAppearance(target.playerInfo) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + packetHex(target.playerInfo) + releaseWorldEntityPacket(observer) + releaseWorldEntityPacket(target) + return observerBytes + } + + fun targetIsHighResolution(): Boolean = TARGET_INDEX in observer.playerInfo.getHighResolutionIndices() + } + + private class StationarySnapshotFixture { + private val context = generateInfoProtocolContext() + private val protocols = context.protocols + private val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + private val targets: Map + + init { + observer.updateRootCoord(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + targets = + listOf(10, 11, 20, 21).associateWith { index -> + protocols.alloc(index, OldSchoolClientType.DESKTOP).also { initializeAppearance(it.playerInfo) } + } + } + + fun updateTarget( + index: Int, + near: Boolean, + xOffset: Int = 1, + ) { + val target = checkNotNull(targets[index]) + val x = if (near) ROOT_X + xOffset else ROOT_X + 104 + xOffset + target.updateRootCoord(0, x, ROOT_Z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + for (target in targets.values) { + packetHex(target.playerInfo) + } + releaseWorldEntityPacket(observer) + for (target in targets.values) { + releaseWorldEntityPacket(target) + } + return observerBytes + } + } + + private companion object { + private const val OBSERVER_INDEX = 500 + private const val TARGET_INDEX = 10 + private const val ROOT_X = 3200 + private const val ROOT_Z = 3200 + private const val INSTANCE_ZONE_X = 800 + private const val INSTANCE_ZONE_Z = 800 + + // Captured from the same scenario on unmodified upstream revision 235 at 7fa6050a. + private val EXPECTED_PACKET_HEX = + listOf( + "00288640b202ff9804bf808080808080808080fe80f4e5e7f2e1d47f7f7f7f7f7f7f7f7f7f7f7f7f7f808080" + + "80808080808080808080808080808080808080808080808080807f7f80", + "807ff0", + "007ff08640b200", + "217ff0", + "217ff0", + "807ff0", + "b8640190007ff08c80e400", + "008a807ff0", + ) + + // Captured from the same scenario on unmodified upstream revision 235 at 7fa6050a. + private val EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX = + listOf( + "00288640b2030c816405ff2004bf808080808080808080fe80f4e5e7f2e1d47f7f7f7f7f7f7f7f7f7f7f7f7f" + + "7f80808080808080808080808080808080808080808080808080808080807f7f8004bf808080808080808080" + + "fe80f4e5e7f2e1d47f7f7f7f7f7f7f7f7f7f7f7f7f7f80808080808080808080808080808080808080808080" + + "808080808080807f7f80", + "98427fec", + "8080308640b2030c816405fe8004bf808080808080808080fe80f4e5e7f2e1d47f7f7f7f7f7f7f7f7f7f7f7f" + + "7f7f80808080808080808080808080808080808080808080808080808080807f7f8004bf8080808080808080" + + "80fe80f4e5e7f2e1d47f7f7f7f7f7f7f7f7f7f7f7f7f7f808080808080808080808080808080808080808080" + + "80808080808080807f7f80", + ) + + private fun instanceCoord( + level: Int, + offset: Int, + zoneX: Int = INSTANCE_ZONE_X, + ): CoordGrid = CoordGrid(level, zoneX * 8 + offset, INSTANCE_ZONE_Z * 8) + + private fun allocWorld( + factory: WorldEntityAvatarFactory, + index: Int, + zoneX: Int, + zoneZ: Int, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = zoneZ, + minLevel = 0, + maxLevel = 1, + fineX = projectedX * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = activeLevel, + angle = 0, + ) + + private fun initializeAppearance(player: PlayerInfo) { + player.avatar.extendedInfo.setName("Target") + player.avatar.extendedInfo.setCombatLevel(126) + player.avatar.extendedInfo.setSkillLevel(0) + player.avatar.extendedInfo.setHidden(false) + player.avatar.extendedInfo.setBodyType(0) + player.avatar.extendedInfo.setPronoun(0) + player.avatar.extendedInfo.setSkullIcon(-1) + player.avatar.extendedInfo.setOverheadIcon(-1) + } + + private fun packetHex(player: PlayerInfo): String { + val packet = checkNotNull(player.internalPacketResult().getOrNull()) + packet.consume() + val buffer = packet.content() + val bytes = ByteArray(buffer.readableBytes()) + buffer.getBytes(buffer.readerIndex(), bytes) + packet.release() + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun releaseWorldEntityPacket(infos: Infos) { + val packet = checkNotNull(infos.getPackets().rootWorldInfoPackets.worldEntityInfo.getOrNull()) + packet.consume() + packet.release() + } + } +} diff --git a/protocol/osrs-235/osrs-235-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-235/osrs-235-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt index 3a795b541..579b4ef11 100644 --- a/protocol/osrs-235/osrs-235-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt +++ b/protocol/osrs-235/osrs-235-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -16,6 +16,7 @@ import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol import net.rsprot.protocol.internal.client.ClientTypeMap @@ -39,7 +40,18 @@ internal fun generateNpcAvatarFactory( internal fun generateInfoProtocols( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), -): InfoProtocols { +): InfoProtocols = generateInfoProtocolContext(npcAvatarFactory, npcIndexStorage).protocols + +internal data class TestInfoProtocolContext( + val protocols: InfoProtocols, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, +) + +internal fun generateInfoProtocolContext( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +74,19 @@ internal fun generateInfoProtocols( protocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) @@ -87,13 +100,17 @@ internal fun generateInfoProtocols( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) - return InfoProtocols( - playerInfoProtocol, - npcInfoProtocol, - worldEntityInfoProtocol, + return TestInfoProtocolContext( + protocols = + InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ), + worldEntityAvatarFactory = worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt index 97698b975..95db65e0e 100644 --- a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt +++ b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -305,7 +305,7 @@ public class PlayerInfo internal constructor( if (isDestroyed()) return ArrayList(0) val collection = ArrayList(highResolutionCount) for (i in 0.. -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +869,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,8 +904,10 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -912,6 +922,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,12 +947,22 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player at its current coordinate. + */ + internal fun resolveWorldEntityIndex(): Int = + checkNotNull(worldEntityInfo) { + "World entity info is null" + }.getWorldEntity(avatar.currentCoord) + /** * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. * The old [buffer] will not be released, as that is the duty of the encoder class. @@ -964,11 +986,13 @@ public class PlayerInfo internal constructor( // Only need to reset the count here, the actual numbers don't matter. extendedInfoCount = 0 for (i in 1 until PROTOCOL_CAPACITY) { - stationary[i] = (stationary[i].toInt() shr 1).toByte() + val stationaryState = (stationary[i].toInt() shr 1).toByte() + stationary[i] = stationaryState + val packedIndex = i or ((stationaryState.toInt() and WAS_STATIONARY) shl STATIONARY_INDEX_SHIFT) if (isHighResolution(i)) { - highResolutionIndices[highResolutionCount++] = i.toShort() + highResolutionIndices[highResolutionCount++] = packedIndex.toShort() } else { - lowResolutionIndices[lowResolutionCount++] = i.toShort() + lowResolutionIndices[lowResolutionCount++] = packedIndex.toShort() } } observerExtendedInfoFlags.reset() @@ -1148,6 +1172,11 @@ public class PlayerInfo internal constructor( */ private const val BUF_CAPACITY: Int = 40_000 + // Player indices only use 11 bits. The sign bit stores the WAS_STATIONARY snapshot + // alongside each resolution-list entry so both protocol passes avoid a dependent array read. + private const val INDEX_MASK: Int = 0x7FFF + private const val STATIONARY_INDEX_SHIFT: Int = 15 + /** * The flag indicating that a player was stationary in the previous cycle. */ diff --git a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt index 4dc3ef68e..9e52f95f8 100644 --- a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt +++ b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -47,6 +47,12 @@ public class PlayerInfoProtocol( private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = GlobalLowResolutionPositionRepository() + /** + * The world entity index containing each active player for the current protocol cycle. + * This is rebuilt before any player update jobs are submitted to the worker. + */ + private val worldEntityIndices: IntArray = IntArray(PROTOCOL_CAPACITY) { WorldEntityInfo.ROOT_WORLD } + /** * The repository responsible for allocating and storing player info instances of * all the avatars that exist. @@ -138,6 +144,11 @@ public class PlayerInfoProtocol( internal fun getLowResolutionPosition(idx: Int): LowResolutionPosition = lowResolutionPositionRepository.getCurrentLowResolutionPosition(idx) + /** + * Gets the world entity index containing the player at [idx] in the current protocol cycle. + */ + internal fun getWorldEntityIndex(idx: Int): Int = worldEntityIndices[idx] + public fun update() { checkCommunicationThread() prepare() @@ -160,6 +171,7 @@ public class PlayerInfoProtocol( // Synchronize the known low res positions of everyone for this cycle for (i in 1.. }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) val playerInfoProtocol = @@ -95,6 +97,7 @@ internal fun generateBenchmarkInfoProtocols( worldEntityInfoProtocol, ), npcAvatarFactory, + worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt index 5ddf2da2a..7ff2a084c 100644 --- a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt +++ b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Measurement import org.openjdk.jmh.annotations.Mode import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -27,34 +28,104 @@ import kotlin.random.Random class PlayerInfoBenchmark { private lateinit var protocols: InfoProtocols private lateinit var players: Array + private lateinit var positions: Array private val random: Random = Random(0) + private var tickCycle: Int = 0 + + @Param + private lateinit var scenario: Scenario + + @Param + private lateinit var activity: Activity @Setup fun setup() { - protocols = + val context = generateBenchmarkInfoProtocols( playerProtocolWorker = DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), - ).protocols + ) + protocols = context.protocols players = arrayOfNulls(PROTOCOL_CAPACITY) + positions = arrayOfNulls(PROTOCOL_CAPACITY) + if (scenario == Scenario.MIXED_WORLD_ENTITIES) { + repeat(WORLD_ENTITY_COUNT) { index -> + context.worldEntityAvatarFactory.alloc( + index = index + 1, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = INSTANCE_ZONE_X + index, + southWestZoneZ = INSTANCE_ZONE_Z, + minLevel = 0, + maxLevel = 0, + fineX = (ROOT_X + index * 2) * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = 0, + angle = 0, + ) + } + } for (i in 1.. + PlayerPosition( + x = ROOT_X + random.nextInt(8), + z = ROOT_Z + random.nextInt(8), + buildAreaCenterX = ROOT_X, + buildAreaCenterZ = ROOT_Z, + ) + Scenario.DISTRIBUTED_ROOT -> { + val x = ROOT_X + (index % 50) * 32 + val z = ROOT_Z + (index / 50) * 32 + PlayerPosition(x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + } else { + val worldIndex = (index - ROOT_PLAYER_COUNT - 1) % WORLD_ENTITY_COUNT + PlayerPosition( + x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, + z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, + buildAreaCenterX = ROOT_X + worldIndex * 2, + buildAreaCenterZ = ROOT_Z, + ) + } + } + } + private fun initializeAppearance( player: PlayerInfo, index: Int, @@ -92,17 +163,17 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + fixture.updateTarget(10, near = true) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = true, xOffset = 2) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false, xOffset = 2) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = false) + fixture.updateTarget(11, near = false) + fixture.updateTarget(20, near = true) + fixture.updateTarget(21, near = true) + packets += fixture.tick() + + assertEquals(EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX, packets) + } + + @Test + fun `stationary player membership is rebuilt after world entity allocation removal and index reuse`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X + 7, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 8, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + val coveringWorld = + fixture.allocWorld( + index = 1, + zoneX = (ROOT_X + 8) ushr 3, + activeLevel = 0, + projectedX = ROOT_X + 104, + zoneZ = ROOT_Z ushr 3, + ) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.releaseWorld(coveringWorld) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.allocWorld( + index = 1, + zoneX = INSTANCE_ZONE_X, + activeLevel = 0, + projectedX = ROOT_X + 104, + ) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `parallel observer updates retain baseline packet bytes`() { + repeat(10) { + assertEquals( + EXPECTED_PACKET_HEX, + membershipTransitionPackets(ForkJoinMultiThreadProtocolWorker()), + ) + } + } + + private fun membershipTransitionPackets(playerProtocolWorker: ProtocolWorker? = null): List { + val fixture = PlayerFixture(playerProtocolWorker) + val packets = ArrayList() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + packets += fixture.tick() + + fixture.target.playerInfo.avatar.hidden = true + packets += fixture.tick() + fixture.target.playerInfo.avatar.hidden = false + packets += fixture.tick() + + val coveringWorld = fixture.allocWorld(1, ROOT_X ushr 3, activeLevel = 0, ROOT_X + 4) + packets += fixture.tick() + fixture.releaseWorld(coveringWorld) + packets += fixture.tick() + + fixture.updateTarget(0, ROOT_X + 104, ROOT_Z) + packets += fixture.tick() + + fixture.allocWorld(2, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 4) + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(instanceCoord(0, 1)) + packets += fixture.tick() + fixture.updateTarget(instanceCoord(1, 1)) + packets += fixture.tick() + return packets + } + + @Test + fun `final movement and player index reuse replace cached membership`() { + val fixture = PlayerFixture() + fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(CoordGrid(0, ROOT_X + 1, ROOT_Z)) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `world entity projection movement uses current root coordinate`() { + val fixture = PlayerFixture() + val world = fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + world.updateCoord(0, ROOT_X * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + world.updateCoord(0, (ROOT_X + 104) * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + } + + @Test + fun `unlimited resize range preserves visibility bypass`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(3, ROOT_X + 104, ROOT_Z + 104) + fixture.observer.playerInfo.avatar.forceResizeRange(Int.MAX_VALUE) + fixture.tick() + + assertTrue(fixture.targetIsHighResolution()) + } + + private class PlayerFixture(playerProtocolWorker: ProtocolWorker? = null) { + private val context = + if (playerProtocolWorker == null) { + generateInfoProtocolContext() + } else { + generateInfoProtocolContext(playerProtocolWorker = playerProtocolWorker) + } + private val protocols = context.protocols + val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + var target: Infos + private set + + init { + updateObserver(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + initializeAppearance(target.playerInfo) + } + + fun updateObserver( + level: Int, + x: Int, + z: Int, + ) { + observer.updateRootCoord(level, x, z) + } + + fun updateObserver(coord: CoordGrid) = updateObserver(coord.level, coord.x, coord.z) + + fun updateTarget( + level: Int, + x: Int, + z: Int, + ) { + target.updateRootCoord(level, x, z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun updateTarget(coord: CoordGrid) = updateTarget(coord.level, coord.x, coord.z) + + fun allocWorld( + index: Int, + zoneX: Int, + activeLevel: Int, + projectedX: Int, + zoneZ: Int = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + ): WorldEntityAvatar = + allocWorld(context.worldEntityAvatarFactory, index, zoneX, zoneZ, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(coord) + initializeAppearance(target.playerInfo) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + packetHex(target.playerInfo) + releaseWorldEntityPacket(observer) + releaseWorldEntityPacket(target) + return observerBytes + } + + fun targetIsHighResolution(): Boolean = TARGET_INDEX in observer.playerInfo.getHighResolutionIndices() + } + + private class StationarySnapshotFixture { + private val context = generateInfoProtocolContext() + private val protocols = context.protocols + private val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + private val targets: Map + + init { + observer.updateRootCoord(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + targets = + listOf(10, 11, 20, 21).associateWith { index -> + protocols.alloc(index, OldSchoolClientType.DESKTOP).also { initializeAppearance(it.playerInfo) } + } + } + + fun updateTarget( + index: Int, + near: Boolean, + xOffset: Int = 1, + ) { + val target = checkNotNull(targets[index]) + val x = if (near) ROOT_X + xOffset else ROOT_X + 104 + xOffset + target.updateRootCoord(0, x, ROOT_Z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + for (target in targets.values) { + packetHex(target.playerInfo) + } + releaseWorldEntityPacket(observer) + for (target in targets.values) { + releaseWorldEntityPacket(target) + } + return observerBytes + } + } + + private companion object { + private const val OBSERVER_INDEX = 500 + private const val TARGET_INDEX = 10 + private const val ROOT_X = 3200 + private const val ROOT_Z = 3200 + private const val INSTANCE_ZONE_X = 800 + private const val INSTANCE_ZONE_Z = 800 + + // Captured from the same scenario on unmodified upstream revision 236 at 7fa6050a. + private val EXPECTED_PACKET_HEX = + listOf( + "00288640b202ff98403f00ffff0000000000000000000000000000000000000000000000000000000000ffff" + + "ffffffffffffffffffffffff546172676574007e000000000000000000", + "807ff0", + "007ff08640b200", + "217ff0", + "217ff0", + "807ff0", + "b8640190007ff08c80e400", + "008a807ff0", + ) + + // Captured from the same scenario on unmodified upstream revision 236 at 7fa6050a. + private val EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX = + listOf( + "00288640b2030c816405ff20403f00ffff000000000000000000000000000000000000000000000000000000" + + "0000ffffffffffffffffffffffffffff546172676574007e000000000000000000403f00ffff000000000000" + + "0000000000000000000000000000000000000000000000ffffffffffffffffffffffffffff54617267657400" + + "7e000000000000000000", + "98427fec", + "8080308640b2030c816405fe80403f00ffff0000000000000000000000000000000000000000000000000000" + + "000000ffffffffffffffffffffffffffff546172676574007e000000000000000000403f00ffff0000000000" + + "000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffff546172676574" + + "007e000000000000000000", + ) + + private fun instanceCoord( + level: Int, + offset: Int, + zoneX: Int = INSTANCE_ZONE_X, + ): CoordGrid = CoordGrid(level, zoneX * 8 + offset, INSTANCE_ZONE_Z * 8) + + private fun allocWorld( + factory: WorldEntityAvatarFactory, + index: Int, + zoneX: Int, + zoneZ: Int, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = zoneZ, + minLevel = 0, + maxLevel = 1, + fineX = projectedX * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = activeLevel, + angle = 0, + ) + + private fun initializeAppearance(player: PlayerInfo) { + player.avatar.extendedInfo.setName("Target") + player.avatar.extendedInfo.setCombatLevel(126) + player.avatar.extendedInfo.setSkillLevel(0) + player.avatar.extendedInfo.setHidden(false) + player.avatar.extendedInfo.setBodyType(0) + player.avatar.extendedInfo.setPronoun(0) + player.avatar.extendedInfo.setSkullIcon(-1) + player.avatar.extendedInfo.setOverheadIcon(-1) + } + + private fun packetHex(player: PlayerInfo): String { + val packet = checkNotNull(player.internalPacketResult().getOrNull()) + packet.consume() + val buffer = packet.content() + val bytes = ByteArray(buffer.readableBytes()) + buffer.getBytes(buffer.readerIndex(), bytes) + packet.release() + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun releaseWorldEntityPacket(infos: Infos) { + val packet = checkNotNull(infos.getPackets().rootWorldInfoPackets.worldEntityInfo.getOrNull()) + packet.consume() + packet.release() + } + } +} diff --git a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt index 3a795b541..579b4ef11 100644 --- a/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt +++ b/protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -16,6 +16,7 @@ import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol import net.rsprot.protocol.internal.client.ClientTypeMap @@ -39,7 +40,18 @@ internal fun generateNpcAvatarFactory( internal fun generateInfoProtocols( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), -): InfoProtocols { +): InfoProtocols = generateInfoProtocolContext(npcAvatarFactory, npcIndexStorage).protocols + +internal data class TestInfoProtocolContext( + val protocols: InfoProtocols, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, +) + +internal fun generateInfoProtocolContext( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +74,19 @@ internal fun generateInfoProtocols( protocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) @@ -87,13 +100,17 @@ internal fun generateInfoProtocols( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) - return InfoProtocols( - playerInfoProtocol, - npcInfoProtocol, - worldEntityInfoProtocol, + return TestInfoProtocolContext( + protocols = + InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ), + worldEntityAvatarFactory = worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt index 97698b975..95db65e0e 100644 --- a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -305,7 +305,7 @@ public class PlayerInfo internal constructor( if (isDestroyed()) return ArrayList(0) val collection = ArrayList(highResolutionCount) for (i in 0.. -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +869,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,8 +904,10 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -912,6 +922,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,12 +947,22 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player at its current coordinate. + */ + internal fun resolveWorldEntityIndex(): Int = + checkNotNull(worldEntityInfo) { + "World entity info is null" + }.getWorldEntity(avatar.currentCoord) + /** * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. * The old [buffer] will not be released, as that is the duty of the encoder class. @@ -964,11 +986,13 @@ public class PlayerInfo internal constructor( // Only need to reset the count here, the actual numbers don't matter. extendedInfoCount = 0 for (i in 1 until PROTOCOL_CAPACITY) { - stationary[i] = (stationary[i].toInt() shr 1).toByte() + val stationaryState = (stationary[i].toInt() shr 1).toByte() + stationary[i] = stationaryState + val packedIndex = i or ((stationaryState.toInt() and WAS_STATIONARY) shl STATIONARY_INDEX_SHIFT) if (isHighResolution(i)) { - highResolutionIndices[highResolutionCount++] = i.toShort() + highResolutionIndices[highResolutionCount++] = packedIndex.toShort() } else { - lowResolutionIndices[lowResolutionCount++] = i.toShort() + lowResolutionIndices[lowResolutionCount++] = packedIndex.toShort() } } observerExtendedInfoFlags.reset() @@ -1148,6 +1172,11 @@ public class PlayerInfo internal constructor( */ private const val BUF_CAPACITY: Int = 40_000 + // Player indices only use 11 bits. The sign bit stores the WAS_STATIONARY snapshot + // alongside each resolution-list entry so both protocol passes avoid a dependent array read. + private const val INDEX_MASK: Int = 0x7FFF + private const val STATIONARY_INDEX_SHIFT: Int = 15 + /** * The flag indicating that a player was stationary in the previous cycle. */ diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt index 4dc3ef68e..9e52f95f8 100644 --- a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -47,6 +47,12 @@ public class PlayerInfoProtocol( private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = GlobalLowResolutionPositionRepository() + /** + * The world entity index containing each active player for the current protocol cycle. + * This is rebuilt before any player update jobs are submitted to the worker. + */ + private val worldEntityIndices: IntArray = IntArray(PROTOCOL_CAPACITY) { WorldEntityInfo.ROOT_WORLD } + /** * The repository responsible for allocating and storing player info instances of * all the avatars that exist. @@ -138,6 +144,11 @@ public class PlayerInfoProtocol( internal fun getLowResolutionPosition(idx: Int): LowResolutionPosition = lowResolutionPositionRepository.getCurrentLowResolutionPosition(idx) + /** + * Gets the world entity index containing the player at [idx] in the current protocol cycle. + */ + internal fun getWorldEntityIndex(idx: Int): Int = worldEntityIndices[idx] + public fun update() { checkCommunicationThread() prepare() @@ -160,6 +171,7 @@ public class PlayerInfoProtocol( // Synchronize the known low res positions of everyone for this cycle for (i in 1.. }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) val playerInfoProtocol = @@ -95,6 +97,7 @@ internal fun generateBenchmarkInfoProtocols( worldEntityInfoProtocol, ), npcAvatarFactory, + worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt index 5ddf2da2a..7ff2a084c 100644 --- a/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt +++ b/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Measurement import org.openjdk.jmh.annotations.Mode import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -27,34 +28,104 @@ import kotlin.random.Random class PlayerInfoBenchmark { private lateinit var protocols: InfoProtocols private lateinit var players: Array + private lateinit var positions: Array private val random: Random = Random(0) + private var tickCycle: Int = 0 + + @Param + private lateinit var scenario: Scenario + + @Param + private lateinit var activity: Activity @Setup fun setup() { - protocols = + val context = generateBenchmarkInfoProtocols( playerProtocolWorker = DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), - ).protocols + ) + protocols = context.protocols players = arrayOfNulls(PROTOCOL_CAPACITY) + positions = arrayOfNulls(PROTOCOL_CAPACITY) + if (scenario == Scenario.MIXED_WORLD_ENTITIES) { + repeat(WORLD_ENTITY_COUNT) { index -> + context.worldEntityAvatarFactory.alloc( + index = index + 1, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = INSTANCE_ZONE_X + index, + southWestZoneZ = INSTANCE_ZONE_Z, + minLevel = 0, + maxLevel = 0, + fineX = (ROOT_X + index * 2) * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = 0, + angle = 0, + ) + } + } for (i in 1.. + PlayerPosition( + x = ROOT_X + random.nextInt(8), + z = ROOT_Z + random.nextInt(8), + buildAreaCenterX = ROOT_X, + buildAreaCenterZ = ROOT_Z, + ) + Scenario.DISTRIBUTED_ROOT -> { + val x = ROOT_X + (index % 50) * 32 + val z = ROOT_Z + (index / 50) * 32 + PlayerPosition(x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + } else { + val worldIndex = (index - ROOT_PLAYER_COUNT - 1) % WORLD_ENTITY_COUNT + PlayerPosition( + x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, + z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, + buildAreaCenterX = ROOT_X + worldIndex * 2, + buildAreaCenterZ = ROOT_Z, + ) + } + } + } + private fun initializeAppearance( player: PlayerInfo, index: Int, @@ -92,17 +163,17 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + fixture.updateTarget(10, near = true) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = true, xOffset = 2) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false, xOffset = 2) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = false) + fixture.updateTarget(11, near = false) + fixture.updateTarget(20, near = true) + fixture.updateTarget(21, near = true) + packets += fixture.tick() + + assertEquals(EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX, packets) + } + + @Test + fun `stationary player membership is rebuilt after world entity allocation removal and index reuse`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X + 7, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 8, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + val coveringWorld = + fixture.allocWorld( + index = 1, + zoneX = (ROOT_X + 8) ushr 3, + activeLevel = 0, + projectedX = ROOT_X + 104, + zoneZ = ROOT_Z ushr 3, + ) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.releaseWorld(coveringWorld) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.allocWorld( + index = 1, + zoneX = INSTANCE_ZONE_X, + activeLevel = 0, + projectedX = ROOT_X + 104, + ) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `parallel observer updates retain baseline packet bytes`() { + repeat(10) { + assertEquals( + EXPECTED_PACKET_HEX, + membershipTransitionPackets(ForkJoinMultiThreadProtocolWorker()), + ) + } + } + + private fun membershipTransitionPackets(playerProtocolWorker: ProtocolWorker? = null): List { + val fixture = PlayerFixture(playerProtocolWorker) + val packets = ArrayList() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + packets += fixture.tick() + + fixture.target.playerInfo.avatar.hidden = true + packets += fixture.tick() + fixture.target.playerInfo.avatar.hidden = false + packets += fixture.tick() + + val coveringWorld = fixture.allocWorld(1, ROOT_X ushr 3, activeLevel = 0, ROOT_X + 4) + packets += fixture.tick() + fixture.releaseWorld(coveringWorld) + packets += fixture.tick() + + fixture.updateTarget(0, ROOT_X + 104, ROOT_Z) + packets += fixture.tick() + + fixture.allocWorld(2, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 4) + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(instanceCoord(0, 1)) + packets += fixture.tick() + fixture.updateTarget(instanceCoord(1, 1)) + packets += fixture.tick() + return packets + } + + @Test + fun `final movement and player index reuse replace cached membership`() { + val fixture = PlayerFixture() + fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(CoordGrid(0, ROOT_X + 1, ROOT_Z)) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `world entity projection movement uses current root coordinate`() { + val fixture = PlayerFixture() + val world = fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + world.updateCoord(0, ROOT_X * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + world.updateCoord(0, (ROOT_X + 104) * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + } + + @Test + fun `unlimited resize range preserves visibility bypass`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(3, ROOT_X + 104, ROOT_Z + 104) + fixture.observer.playerInfo.avatar.forceResizeRange(Int.MAX_VALUE) + fixture.tick() + + assertTrue(fixture.targetIsHighResolution()) + } + + private class PlayerFixture(playerProtocolWorker: ProtocolWorker? = null) { + private val context = + if (playerProtocolWorker == null) { + generateInfoProtocolContext() + } else { + generateInfoProtocolContext(playerProtocolWorker = playerProtocolWorker) + } + private val protocols = context.protocols + val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + var target: Infos + private set + + init { + updateObserver(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + initializeAppearance(target.playerInfo) + } + + fun updateObserver( + level: Int, + x: Int, + z: Int, + ) { + observer.updateRootCoord(level, x, z) + } + + fun updateObserver(coord: CoordGrid) = updateObserver(coord.level, coord.x, coord.z) + + fun updateTarget( + level: Int, + x: Int, + z: Int, + ) { + target.updateRootCoord(level, x, z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun updateTarget(coord: CoordGrid) = updateTarget(coord.level, coord.x, coord.z) + + fun allocWorld( + index: Int, + zoneX: Int, + activeLevel: Int, + projectedX: Int, + zoneZ: Int = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + ): WorldEntityAvatar = + allocWorld(context.worldEntityAvatarFactory, index, zoneX, zoneZ, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(coord) + initializeAppearance(target.playerInfo) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + packetHex(target.playerInfo) + releaseWorldEntityPacket(observer) + releaseWorldEntityPacket(target) + return observerBytes + } + + fun targetIsHighResolution(): Boolean = TARGET_INDEX in observer.playerInfo.getHighResolutionIndices() + } + + private class StationarySnapshotFixture { + private val context = generateInfoProtocolContext() + private val protocols = context.protocols + private val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + private val targets: Map + + init { + observer.updateRootCoord(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + targets = + listOf(10, 11, 20, 21).associateWith { index -> + protocols.alloc(index, OldSchoolClientType.DESKTOP).also { initializeAppearance(it.playerInfo) } + } + } + + fun updateTarget( + index: Int, + near: Boolean, + xOffset: Int = 1, + ) { + val target = checkNotNull(targets[index]) + val x = if (near) ROOT_X + xOffset else ROOT_X + 104 + xOffset + target.updateRootCoord(0, x, ROOT_Z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + for (target in targets.values) { + packetHex(target.playerInfo) + } + releaseWorldEntityPacket(observer) + for (target in targets.values) { + releaseWorldEntityPacket(target) + } + return observerBytes + } + } + + private companion object { + private const val OBSERVER_INDEX = 500 + private const val TARGET_INDEX = 10 + private const val ROOT_X = 3200 + private const val ROOT_Z = 3200 + private const val INSTANCE_ZONE_X = 800 + private const val INSTANCE_ZONE_Z = 800 + + // Captured from the same scenario on unmodified upstream revision 237 at 7fa6050a. + private val EXPECTED_PACKET_HEX = + listOf( + "00288640b202ff98023f00ffff0000000000000000000000000000000000000000000000000000000000" + + "ffffffffffffffffffffffffffff546172676574007e000000000000000000", + "807ff0", + "007ff08640b200", + "217ff0", + "217ff0", + "807ff0", + "b8640190007ff08c80e400", + "008a807ff0", + ) + + // Captured from the same scenario on unmodified upstream revision 237 at 7fa6050a. + private val EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX = + listOf( + "00288640b2030c816405ff20023f00ffff000000000000000000000000000000000000000000000000" + + "0000000000ffffffffffffffffffffffffffff546172676574007e000000000000000000023f00ffff0000" + + "000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffff54" + + "6172676574007e000000000000000000", + "98427fec", + "8080308640b2030c816405fe80023f00ffff000000000000000000000000000000000000000000000000" + + "0000000000ffffffffffffffffffffffffffff546172676574007e000000000000000000023f00ffff0000" + + "000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffff54" + + "6172676574007e000000000000000000", + ) + + private fun instanceCoord( + level: Int, + offset: Int, + zoneX: Int = INSTANCE_ZONE_X, + ): CoordGrid = CoordGrid(level, zoneX * 8 + offset, INSTANCE_ZONE_Z * 8) + + private fun allocWorld( + factory: WorldEntityAvatarFactory, + index: Int, + zoneX: Int, + zoneZ: Int, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = zoneZ, + minLevel = 0, + maxLevel = 1, + fineX = projectedX * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = activeLevel, + angle = 0, + ) + + private fun initializeAppearance(player: PlayerInfo) { + player.avatar.extendedInfo.setName("Target") + player.avatar.extendedInfo.setCombatLevel(126) + player.avatar.extendedInfo.setSkillLevel(0) + player.avatar.extendedInfo.setHidden(false) + player.avatar.extendedInfo.setBodyType(0) + player.avatar.extendedInfo.setPronoun(0) + player.avatar.extendedInfo.setSkullIcon(-1) + player.avatar.extendedInfo.setOverheadIcon(-1) + } + + private fun packetHex(player: PlayerInfo): String { + val packet = checkNotNull(player.internalPacketResult().getOrNull()) + packet.consume() + val buffer = packet.content() + val bytes = ByteArray(buffer.readableBytes()) + buffer.getBytes(buffer.readerIndex(), bytes) + packet.release() + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun releaseWorldEntityPacket(infos: Infos) { + val packet = checkNotNull(infos.getPackets().rootWorldInfoPackets.worldEntityInfo.getOrNull()) + packet.consume() + packet.release() + } + } +} diff --git a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt index 3a795b541..579b4ef11 100644 --- a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt +++ b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -16,6 +16,7 @@ import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol import net.rsprot.protocol.internal.client.ClientTypeMap @@ -39,7 +40,18 @@ internal fun generateNpcAvatarFactory( internal fun generateInfoProtocols( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), -): InfoProtocols { +): InfoProtocols = generateInfoProtocolContext(npcAvatarFactory, npcIndexStorage).protocols + +internal data class TestInfoProtocolContext( + val protocols: InfoProtocols, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, +) + +internal fun generateInfoProtocolContext( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +74,19 @@ internal fun generateInfoProtocols( protocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) @@ -87,13 +100,17 @@ internal fun generateInfoProtocols( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) - return InfoProtocols( - playerInfoProtocol, - npcInfoProtocol, - worldEntityInfoProtocol, + return TestInfoProtocolContext( + protocols = + InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ), + worldEntityAvatarFactory = worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt index 43e032761..09c578914 100644 --- a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt +++ b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -305,7 +305,7 @@ public class PlayerInfo internal constructor( if (isDestroyed()) return ArrayList(0) val collection = ArrayList(highResolutionCount) for (i in 0.. -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +869,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,8 +904,10 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -912,6 +922,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,12 +947,22 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player at its current coordinate. + */ + internal fun resolveWorldEntityIndex(): Int = + checkNotNull(worldEntityInfo) { + "World entity info is null" + }.getWorldEntity(avatar.currentCoord) + /** * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. * The old [buffer] will not be released, as that is the duty of the encoder class. @@ -964,11 +986,13 @@ public class PlayerInfo internal constructor( // Only need to reset the count here, the actual numbers don't matter. extendedInfoCount = 0 for (i in 1 until PROTOCOL_CAPACITY) { - stationary[i] = (stationary[i].toInt() shr 1).toByte() + val stationaryState = (stationary[i].toInt() shr 1).toByte() + stationary[i] = stationaryState + val packedIndex = i or ((stationaryState.toInt() and WAS_STATIONARY) shl STATIONARY_INDEX_SHIFT) if (isHighResolution(i)) { - highResolutionIndices[highResolutionCount++] = i.toShort() + highResolutionIndices[highResolutionCount++] = packedIndex.toShort() } else { - lowResolutionIndices[lowResolutionCount++] = i.toShort() + lowResolutionIndices[lowResolutionCount++] = packedIndex.toShort() } } observerExtendedInfoFlags.reset() @@ -1148,6 +1172,11 @@ public class PlayerInfo internal constructor( */ private const val BUF_CAPACITY: Int = 40_000 + // Player indices only use 11 bits. The sign bit stores the WAS_STATIONARY snapshot + // alongside each resolution-list entry so both protocol passes avoid a dependent array read. + private const val INDEX_MASK: Int = 0x7FFF + private const val STATIONARY_INDEX_SHIFT: Int = 15 + /** * The flag indicating that a player was stationary in the previous cycle. */ diff --git a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt index 4dc3ef68e..9e52f95f8 100644 --- a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt +++ b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -47,6 +47,12 @@ public class PlayerInfoProtocol( private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = GlobalLowResolutionPositionRepository() + /** + * The world entity index containing each active player for the current protocol cycle. + * This is rebuilt before any player update jobs are submitted to the worker. + */ + private val worldEntityIndices: IntArray = IntArray(PROTOCOL_CAPACITY) { WorldEntityInfo.ROOT_WORLD } + /** * The repository responsible for allocating and storing player info instances of * all the avatars that exist. @@ -138,6 +144,11 @@ public class PlayerInfoProtocol( internal fun getLowResolutionPosition(idx: Int): LowResolutionPosition = lowResolutionPositionRepository.getCurrentLowResolutionPosition(idx) + /** + * Gets the world entity index containing the player at [idx] in the current protocol cycle. + */ + internal fun getWorldEntityIndex(idx: Int): Int = worldEntityIndices[idx] + public fun update() { checkCommunicationThread() prepare() @@ -160,6 +171,7 @@ public class PlayerInfoProtocol( // Synchronize the known low res positions of everyone for this cycle for (i in 1.. }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) val playerInfoProtocol = @@ -95,6 +97,7 @@ internal fun generateBenchmarkInfoProtocols( worldEntityInfoProtocol, ), npcAvatarFactory, + worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt index 5ddf2da2a..7ff2a084c 100644 --- a/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt +++ b/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Measurement import org.openjdk.jmh.annotations.Mode import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -27,34 +28,104 @@ import kotlin.random.Random class PlayerInfoBenchmark { private lateinit var protocols: InfoProtocols private lateinit var players: Array + private lateinit var positions: Array private val random: Random = Random(0) + private var tickCycle: Int = 0 + + @Param + private lateinit var scenario: Scenario + + @Param + private lateinit var activity: Activity @Setup fun setup() { - protocols = + val context = generateBenchmarkInfoProtocols( playerProtocolWorker = DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), - ).protocols + ) + protocols = context.protocols players = arrayOfNulls(PROTOCOL_CAPACITY) + positions = arrayOfNulls(PROTOCOL_CAPACITY) + if (scenario == Scenario.MIXED_WORLD_ENTITIES) { + repeat(WORLD_ENTITY_COUNT) { index -> + context.worldEntityAvatarFactory.alloc( + index = index + 1, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = INSTANCE_ZONE_X + index, + southWestZoneZ = INSTANCE_ZONE_Z, + minLevel = 0, + maxLevel = 0, + fineX = (ROOT_X + index * 2) * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = 0, + angle = 0, + ) + } + } for (i in 1.. + PlayerPosition( + x = ROOT_X + random.nextInt(8), + z = ROOT_Z + random.nextInt(8), + buildAreaCenterX = ROOT_X, + buildAreaCenterZ = ROOT_Z, + ) + Scenario.DISTRIBUTED_ROOT -> { + val x = ROOT_X + (index % 50) * 32 + val z = ROOT_Z + (index / 50) * 32 + PlayerPosition(x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + } else { + val worldIndex = (index - ROOT_PLAYER_COUNT - 1) % WORLD_ENTITY_COUNT + PlayerPosition( + x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, + z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, + buildAreaCenterX = ROOT_X + worldIndex * 2, + buildAreaCenterZ = ROOT_Z, + ) + } + } + } + private fun initializeAppearance( player: PlayerInfo, index: Int, @@ -92,17 +163,17 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + fixture.updateTarget(10, near = true) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = true, xOffset = 2) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false, xOffset = 2) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = false) + fixture.updateTarget(11, near = false) + fixture.updateTarget(20, near = true) + fixture.updateTarget(21, near = true) + packets += fixture.tick() + + assertEquals(EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX, packets) + } + + @Test + fun `stationary player membership is rebuilt after world entity allocation removal and index reuse`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X + 7, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 8, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + val coveringWorld = + fixture.allocWorld( + index = 1, + zoneX = (ROOT_X + 8) ushr 3, + activeLevel = 0, + projectedX = ROOT_X + 104, + zoneZ = ROOT_Z ushr 3, + ) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.releaseWorld(coveringWorld) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.allocWorld( + index = 1, + zoneX = INSTANCE_ZONE_X, + activeLevel = 0, + projectedX = ROOT_X + 104, + ) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `parallel observer updates retain baseline packet bytes`() { + repeat(10) { + assertEquals( + EXPECTED_PACKET_HEX, + membershipTransitionPackets(ForkJoinMultiThreadProtocolWorker()), + ) + } + } + + private fun membershipTransitionPackets(playerProtocolWorker: ProtocolWorker? = null): List { + val fixture = PlayerFixture(playerProtocolWorker) + val packets = ArrayList() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + packets += fixture.tick() + + fixture.target.playerInfo.avatar.hidden = true + packets += fixture.tick() + fixture.target.playerInfo.avatar.hidden = false + packets += fixture.tick() + + val coveringWorld = fixture.allocWorld(1, ROOT_X ushr 3, activeLevel = 0, ROOT_X + 4) + packets += fixture.tick() + fixture.releaseWorld(coveringWorld) + packets += fixture.tick() + + fixture.updateTarget(0, ROOT_X + 104, ROOT_Z) + packets += fixture.tick() + + fixture.allocWorld(2, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 4) + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(instanceCoord(0, 1)) + packets += fixture.tick() + fixture.updateTarget(instanceCoord(1, 1)) + packets += fixture.tick() + return packets + } + + @Test + fun `final movement and player index reuse replace cached membership`() { + val fixture = PlayerFixture() + fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(CoordGrid(0, ROOT_X + 1, ROOT_Z)) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `world entity projection movement uses current root coordinate`() { + val fixture = PlayerFixture() + val world = fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + world.updateCoord(0, ROOT_X * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + world.updateCoord(0, (ROOT_X + 104) * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + } + + @Test + fun `unlimited resize range preserves visibility bypass`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(3, ROOT_X + 104, ROOT_Z + 104) + fixture.observer.playerInfo.avatar.forceResizeRange(Int.MAX_VALUE) + fixture.tick() + + assertTrue(fixture.targetIsHighResolution()) + } + + private class PlayerFixture(playerProtocolWorker: ProtocolWorker? = null) { + private val context = + if (playerProtocolWorker == null) { + generateInfoProtocolContext() + } else { + generateInfoProtocolContext(playerProtocolWorker = playerProtocolWorker) + } + private val protocols = context.protocols + val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + var target: Infos + private set + + init { + updateObserver(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + initializeAppearance(target.playerInfo) + } + + fun updateObserver( + level: Int, + x: Int, + z: Int, + ) { + observer.updateRootCoord(level, x, z) + } + + fun updateObserver(coord: CoordGrid) = updateObserver(coord.level, coord.x, coord.z) + + fun updateTarget( + level: Int, + x: Int, + z: Int, + ) { + target.updateRootCoord(level, x, z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun updateTarget(coord: CoordGrid) = updateTarget(coord.level, coord.x, coord.z) + + fun allocWorld( + index: Int, + zoneX: Int, + activeLevel: Int, + projectedX: Int, + zoneZ: Int = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + ): WorldEntityAvatar = + allocWorld(context.worldEntityAvatarFactory, index, zoneX, zoneZ, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(coord) + initializeAppearance(target.playerInfo) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + packetHex(target.playerInfo) + releaseWorldEntityPacket(observer) + releaseWorldEntityPacket(target) + return observerBytes + } + + fun targetIsHighResolution(): Boolean = TARGET_INDEX in observer.playerInfo.getHighResolutionIndices() + } + + private class StationarySnapshotFixture { + private val context = generateInfoProtocolContext() + private val protocols = context.protocols + private val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + private val targets: Map + + init { + observer.updateRootCoord(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + targets = + listOf(10, 11, 20, 21).associateWith { index -> + protocols.alloc(index, OldSchoolClientType.DESKTOP).also { initializeAppearance(it.playerInfo) } + } + } + + fun updateTarget( + index: Int, + near: Boolean, + xOffset: Int = 1, + ) { + val target = checkNotNull(targets[index]) + val x = if (near) ROOT_X + xOffset else ROOT_X + 104 + xOffset + target.updateRootCoord(0, x, ROOT_Z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + for (target in targets.values) { + packetHex(target.playerInfo) + } + releaseWorldEntityPacket(observer) + for (target in targets.values) { + releaseWorldEntityPacket(target) + } + return observerBytes + } + } + + private companion object { + private const val OBSERVER_INDEX = 500 + private const val TARGET_INDEX = 10 + private const val ROOT_X = 3200 + private const val ROOT_Z = 3200 + private const val INSTANCE_ZONE_X = 800 + private const val INSTANCE_ZONE_Z = 800 + + // Captured from the same scenario on unmodified upstream revision 238 at 7fa6050a. + private val EXPECTED_PACKET_HEX = + listOf( + "00288640b202ff9804bf807f7f80808080808080808080808080808080808080808080808080808080807f7f" + + "7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe808080808080808080", + "807ff0", + "007ff08640b200", + "217ff0", + "217ff0", + "807ff0", + "b8640190007ff08c80e400", + "008a807ff0", + ) + + // Captured from the same scenario on unmodified upstream revision 238 at 7fa6050a. + private val EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX = + listOf( + "00288640b2030c816405ff2004bf807f7f808080808080808080808080808080808080808080808080808080" + + "80807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe80808080808080808004bf807f7f808080808080" + + "80808080808080808080808080808080808080808080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480" + + "fe808080808080808080", + "98427fec", + "8080308640b2030c816405fe8004bf807f7f8080808080808080808080808080808080808080808080808080" + + "8080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe80808080808080808004bf807f7f8080808080" + + "8080808080808080808080808080808080808080808080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f4" + + "80fe808080808080808080", + ) + + private fun instanceCoord( + level: Int, + offset: Int, + zoneX: Int = INSTANCE_ZONE_X, + ): CoordGrid = CoordGrid(level, zoneX * 8 + offset, INSTANCE_ZONE_Z * 8) + + private fun allocWorld( + factory: WorldEntityAvatarFactory, + index: Int, + zoneX: Int, + zoneZ: Int, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = zoneZ, + minLevel = 0, + maxLevel = 1, + fineX = projectedX * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = activeLevel, + angle = 0, + ) + + private fun initializeAppearance(player: PlayerInfo) { + player.avatar.extendedInfo.setName("Target") + player.avatar.extendedInfo.setCombatLevel(126) + player.avatar.extendedInfo.setSkillLevel(0) + player.avatar.extendedInfo.setHidden(false) + player.avatar.extendedInfo.setBodyType(0) + player.avatar.extendedInfo.setPronoun(0) + player.avatar.extendedInfo.setSkullIcon(-1) + player.avatar.extendedInfo.setOverheadIcon(-1) + } + + private fun packetHex(player: PlayerInfo): String { + val packet = checkNotNull(player.internalPacketResult().getOrNull()) + packet.consume() + val buffer = packet.content() + val bytes = ByteArray(buffer.readableBytes()) + buffer.getBytes(buffer.readerIndex(), bytes) + packet.release() + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun releaseWorldEntityPacket(infos: Infos) { + val packet = checkNotNull(infos.getPackets().rootWorldInfoPackets.worldEntityInfo.getOrNull()) + packet.consume() + packet.release() + } + } +} diff --git a/protocol/osrs-238/osrs-238-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-238/osrs-238-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt index 3a795b541..579b4ef11 100644 --- a/protocol/osrs-238/osrs-238-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt +++ b/protocol/osrs-238/osrs-238-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -16,6 +16,7 @@ import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol import net.rsprot.protocol.internal.client.ClientTypeMap @@ -39,7 +40,18 @@ internal fun generateNpcAvatarFactory( internal fun generateInfoProtocols( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), -): InfoProtocols { +): InfoProtocols = generateInfoProtocolContext(npcAvatarFactory, npcIndexStorage).protocols + +internal data class TestInfoProtocolContext( + val protocols: InfoProtocols, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, +) + +internal fun generateInfoProtocolContext( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +74,19 @@ internal fun generateInfoProtocols( protocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) @@ -87,13 +100,17 @@ internal fun generateInfoProtocols( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) - return InfoProtocols( - playerInfoProtocol, - npcInfoProtocol, - worldEntityInfoProtocol, + return TestInfoProtocolContext( + protocols = + InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ), + worldEntityAvatarFactory = worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt index 43e032761..09c578914 100644 --- a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt +++ b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -305,7 +305,7 @@ public class PlayerInfo internal constructor( if (isDestroyed()) return ArrayList(0) val collection = ArrayList(highResolutionCount) for (i in 0.. -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +869,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,8 +904,10 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -912,6 +922,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,12 +947,22 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player at its current coordinate. + */ + internal fun resolveWorldEntityIndex(): Int = + checkNotNull(worldEntityInfo) { + "World entity info is null" + }.getWorldEntity(avatar.currentCoord) + /** * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. * The old [buffer] will not be released, as that is the duty of the encoder class. @@ -964,11 +986,13 @@ public class PlayerInfo internal constructor( // Only need to reset the count here, the actual numbers don't matter. extendedInfoCount = 0 for (i in 1 until PROTOCOL_CAPACITY) { - stationary[i] = (stationary[i].toInt() shr 1).toByte() + val stationaryState = (stationary[i].toInt() shr 1).toByte() + stationary[i] = stationaryState + val packedIndex = i or ((stationaryState.toInt() and WAS_STATIONARY) shl STATIONARY_INDEX_SHIFT) if (isHighResolution(i)) { - highResolutionIndices[highResolutionCount++] = i.toShort() + highResolutionIndices[highResolutionCount++] = packedIndex.toShort() } else { - lowResolutionIndices[lowResolutionCount++] = i.toShort() + lowResolutionIndices[lowResolutionCount++] = packedIndex.toShort() } } observerExtendedInfoFlags.reset() @@ -1148,6 +1172,11 @@ public class PlayerInfo internal constructor( */ private const val BUF_CAPACITY: Int = 40_000 + // Player indices only use 11 bits. The sign bit stores the WAS_STATIONARY snapshot + // alongside each resolution-list entry so both protocol passes avoid a dependent array read. + private const val INDEX_MASK: Int = 0x7FFF + private const val STATIONARY_INDEX_SHIFT: Int = 15 + /** * The flag indicating that a player was stationary in the previous cycle. */ diff --git a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt index 4dc3ef68e..9e52f95f8 100644 --- a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt +++ b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -47,6 +47,12 @@ public class PlayerInfoProtocol( private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = GlobalLowResolutionPositionRepository() + /** + * The world entity index containing each active player for the current protocol cycle. + * This is rebuilt before any player update jobs are submitted to the worker. + */ + private val worldEntityIndices: IntArray = IntArray(PROTOCOL_CAPACITY) { WorldEntityInfo.ROOT_WORLD } + /** * The repository responsible for allocating and storing player info instances of * all the avatars that exist. @@ -138,6 +144,11 @@ public class PlayerInfoProtocol( internal fun getLowResolutionPosition(idx: Int): LowResolutionPosition = lowResolutionPositionRepository.getCurrentLowResolutionPosition(idx) + /** + * Gets the world entity index containing the player at [idx] in the current protocol cycle. + */ + internal fun getWorldEntityIndex(idx: Int): Int = worldEntityIndices[idx] + public fun update() { checkCommunicationThread() prepare() @@ -160,6 +171,7 @@ public class PlayerInfoProtocol( // Synchronize the known low res positions of everyone for this cycle for (i in 1.. }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) val playerInfoProtocol = @@ -95,6 +97,7 @@ internal fun generateBenchmarkInfoProtocols( worldEntityInfoProtocol, ), npcAvatarFactory, + worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt b/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt index 5ddf2da2a..7ff2a084c 100644 --- a/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt +++ b/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoBenchmark.kt @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Measurement import org.openjdk.jmh.annotations.Mode import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -27,34 +28,104 @@ import kotlin.random.Random class PlayerInfoBenchmark { private lateinit var protocols: InfoProtocols private lateinit var players: Array + private lateinit var positions: Array private val random: Random = Random(0) + private var tickCycle: Int = 0 + + @Param + private lateinit var scenario: Scenario + + @Param + private lateinit var activity: Activity @Setup fun setup() { - protocols = + val context = generateBenchmarkInfoProtocols( playerProtocolWorker = DefaultProtocolWorker(Int.MAX_VALUE, ForkJoinPool.commonPool()), - ).protocols + ) + protocols = context.protocols players = arrayOfNulls(PROTOCOL_CAPACITY) + positions = arrayOfNulls(PROTOCOL_CAPACITY) + if (scenario == Scenario.MIXED_WORLD_ENTITIES) { + repeat(WORLD_ENTITY_COUNT) { index -> + context.worldEntityAvatarFactory.alloc( + index = index + 1, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = INSTANCE_ZONE_X + index, + southWestZoneZ = INSTANCE_ZONE_Z, + minLevel = 0, + maxLevel = 0, + fineX = (ROOT_X + index * 2) * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = 0, + angle = 0, + ) + } + } for (i in 1.. + PlayerPosition( + x = ROOT_X + random.nextInt(8), + z = ROOT_Z + random.nextInt(8), + buildAreaCenterX = ROOT_X, + buildAreaCenterZ = ROOT_Z, + ) + Scenario.DISTRIBUTED_ROOT -> { + val x = ROOT_X + (index % 50) * 32 + val z = ROOT_Z + (index / 50) * 32 + PlayerPosition(x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + } else { + val worldIndex = (index - ROOT_PLAYER_COUNT - 1) % WORLD_ENTITY_COUNT + PlayerPosition( + x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, + z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, + buildAreaCenterX = ROOT_X + worldIndex * 2, + buildAreaCenterZ = ROOT_Z, + ) + } + } + } + private fun initializeAppearance( player: PlayerInfo, index: Int, @@ -92,17 +163,17 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + fixture.updateTarget(10, near = true) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = true, xOffset = 2) + fixture.updateTarget(11, near = true) + fixture.updateTarget(20, near = false, xOffset = 2) + fixture.updateTarget(21, near = false) + packets += fixture.tick() + + fixture.updateTarget(10, near = false) + fixture.updateTarget(11, near = false) + fixture.updateTarget(20, near = true) + fixture.updateTarget(21, near = true) + packets += fixture.tick() + + assertEquals(EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX, packets) + } + + @Test + fun `stationary player membership is rebuilt after world entity allocation removal and index reuse`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X + 7, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 8, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + val coveringWorld = + fixture.allocWorld( + index = 1, + zoneX = (ROOT_X + 8) ushr 3, + activeLevel = 0, + projectedX = ROOT_X + 104, + zoneZ = ROOT_Z ushr 3, + ) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.releaseWorld(coveringWorld) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.allocWorld( + index = 1, + zoneX = INSTANCE_ZONE_X, + activeLevel = 0, + projectedX = ROOT_X + 104, + ) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `parallel observer updates retain baseline packet bytes`() { + repeat(10) { + assertEquals( + EXPECTED_PACKET_HEX, + membershipTransitionPackets(ForkJoinMultiThreadProtocolWorker()), + ) + } + } + + private fun membershipTransitionPackets(playerProtocolWorker: ProtocolWorker? = null): List { + val fixture = PlayerFixture(playerProtocolWorker) + val packets = ArrayList() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + packets += fixture.tick() + + fixture.target.playerInfo.avatar.hidden = true + packets += fixture.tick() + fixture.target.playerInfo.avatar.hidden = false + packets += fixture.tick() + + val coveringWorld = fixture.allocWorld(1, ROOT_X ushr 3, activeLevel = 0, ROOT_X + 4) + packets += fixture.tick() + fixture.releaseWorld(coveringWorld) + packets += fixture.tick() + + fixture.updateTarget(0, ROOT_X + 104, ROOT_Z) + packets += fixture.tick() + + fixture.allocWorld(2, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 4) + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(instanceCoord(0, 1)) + packets += fixture.tick() + fixture.updateTarget(instanceCoord(1, 1)) + packets += fixture.tick() + return packets + } + + @Test + fun `final movement and player index reuse replace cached membership`() { + val fixture = PlayerFixture() + fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.updateTarget(instanceCoord(0, 1)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(instanceCoord(0, 1)) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + fixture.reallocateTarget(CoordGrid(0, ROOT_X + 1, ROOT_Z)) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + } + + @Test + fun `world entity projection movement uses current root coordinate`() { + val fixture = PlayerFixture() + val world = fixture.allocWorld(1, INSTANCE_ZONE_X, activeLevel = 0, ROOT_X + 104) + + fixture.updateObserver(instanceCoord(0, 0)) + fixture.updateTarget(0, ROOT_X + 1, ROOT_Z) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + + world.updateCoord(0, ROOT_X * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertTrue(fixture.targetIsHighResolution()) + + world.updateCoord(0, (ROOT_X + 104) * 128 + 64, ROOT_Z * 128 + 64, teleport = true) + fixture.tick() + assertFalse(fixture.targetIsHighResolution()) + } + + @Test + fun `unlimited resize range preserves visibility bypass`() { + val fixture = PlayerFixture() + + fixture.updateObserver(0, ROOT_X, ROOT_Z) + fixture.updateTarget(3, ROOT_X + 104, ROOT_Z + 104) + fixture.observer.playerInfo.avatar.forceResizeRange(Int.MAX_VALUE) + fixture.tick() + + assertTrue(fixture.targetIsHighResolution()) + } + + private class PlayerFixture(playerProtocolWorker: ProtocolWorker? = null) { + private val context = + if (playerProtocolWorker == null) { + generateInfoProtocolContext() + } else { + generateInfoProtocolContext(playerProtocolWorker = playerProtocolWorker) + } + private val protocols = context.protocols + val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + var target: Infos + private set + + init { + updateObserver(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + initializeAppearance(target.playerInfo) + } + + fun updateObserver( + level: Int, + x: Int, + z: Int, + ) { + observer.updateRootCoord(level, x, z) + } + + fun updateObserver(coord: CoordGrid) = updateObserver(coord.level, coord.x, coord.z) + + fun updateTarget( + level: Int, + x: Int, + z: Int, + ) { + target.updateRootCoord(level, x, z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun updateTarget(coord: CoordGrid) = updateTarget(coord.level, coord.x, coord.z) + + fun allocWorld( + index: Int, + zoneX: Int, + activeLevel: Int, + projectedX: Int, + zoneZ: Int = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + ): WorldEntityAvatar = + allocWorld(context.worldEntityAvatarFactory, index, zoneX, zoneZ, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(coord) + initializeAppearance(target.playerInfo) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + packetHex(target.playerInfo) + releaseWorldEntityPacket(observer) + releaseWorldEntityPacket(target) + return observerBytes + } + + fun targetIsHighResolution(): Boolean = TARGET_INDEX in observer.playerInfo.getHighResolutionIndices() + } + + private class StationarySnapshotFixture { + private val context = generateInfoProtocolContext() + private val protocols = context.protocols + private val observer: Infos = protocols.alloc(OBSERVER_INDEX, OldSchoolClientType.DESKTOP) + private val targets: Map + + init { + observer.updateRootCoord(0, ROOT_X, ROOT_Z) + observer.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + val gpiBuffer = Unpooled.buffer(5000) + observer.playerInfo.handleAbsolutePlayerPositions(gpiBuffer) + gpiBuffer.release() + targets = + listOf(10, 11, 20, 21).associateWith { index -> + protocols.alloc(index, OldSchoolClientType.DESKTOP).also { initializeAppearance(it.playerInfo) } + } + } + + fun updateTarget( + index: Int, + near: Boolean, + xOffset: Int = 1, + ) { + val target = checkNotNull(targets[index]) + val x = if (near) ROOT_X + xOffset else ROOT_X + 104 + xOffset + target.updateRootCoord(0, x, ROOT_Z) + target.updateRootBuildAreaCenteredOnPlayer(ROOT_X, ROOT_Z) + } + + fun tick(): String { + protocols.worldEntityInfoProtocol.update() + protocols.playerInfoProtocol.update() + val observerBytes = packetHex(observer.playerInfo) + for (target in targets.values) { + packetHex(target.playerInfo) + } + releaseWorldEntityPacket(observer) + for (target in targets.values) { + releaseWorldEntityPacket(target) + } + return observerBytes + } + } + + private companion object { + private const val OBSERVER_INDEX = 500 + private const val TARGET_INDEX = 10 + private const val ROOT_X = 3200 + private const val ROOT_Z = 3200 + private const val INSTANCE_ZONE_X = 800 + private const val INSTANCE_ZONE_Z = 800 + + // Captured from the same scenario on unmodified upstream revision 239 at 7fa6050a. + private val EXPECTED_PACKET_HEX = + listOf( + "00288640b202ff982041807f7f80808080808080808080808080808080808080808080808080808080807f7f" + + "7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe808080808080808080", + "807ff0", + "007ff08640b200", + "217ff0", + "217ff0", + "807ff0", + "b8640190007ff08c80e400", + "008a807ff0", + ) + + // Captured from the same scenario on unmodified upstream revision 239 at 7fa6050a. + private val EXPECTED_STATIONARY_SNAPSHOT_PACKET_HEX = + listOf( + "00288640b2030c816405ff202041807f7f808080808080808080808080808080808080808080808080808080" + + "80807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe8080808080808080802041807f7f808080808080" + + "80808080808080808080808080808080808080808080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480" + + "fe808080808080808080", + "98427fec", + "8080308640b2030c816405fe802041807f7f8080808080808080808080808080808080808080808080808080" + + "8080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f480fe8080808080808080802041807f7f8080808080" + + "8080808080808080808080808080808080808080808080807f7f7f7f7f7f7f7f7f7f7f7f7f7fd4e1f2e7e5f4" + + "80fe808080808080808080", + ) + + private fun instanceCoord( + level: Int, + offset: Int, + zoneX: Int = INSTANCE_ZONE_X, + ): CoordGrid = CoordGrid(level, zoneX * 8 + offset, INSTANCE_ZONE_Z * 8) + + private fun allocWorld( + factory: WorldEntityAvatarFactory, + index: Int, + zoneX: Int, + zoneZ: Int, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = zoneZ, + minLevel = 0, + maxLevel = 1, + fineX = projectedX * 128 + 64, + fineZ = ROOT_Z * 128 + 64, + projectedLevel = 0, + activeLevel = activeLevel, + angle = 0, + ) + + private fun initializeAppearance(player: PlayerInfo) { + player.avatar.extendedInfo.setName("Target") + player.avatar.extendedInfo.setCombatLevel(126) + player.avatar.extendedInfo.setSkillLevel(0) + player.avatar.extendedInfo.setHidden(false) + player.avatar.extendedInfo.setBodyType(0) + player.avatar.extendedInfo.setPronoun(0) + player.avatar.extendedInfo.setSkullIcon(-1) + player.avatar.extendedInfo.setOverheadIcon(-1) + } + + private fun packetHex(player: PlayerInfo): String { + val packet = checkNotNull(player.internalPacketResult().getOrNull()) + packet.consume() + val buffer = packet.content() + val bytes = ByteArray(buffer.readableBytes()) + buffer.getBytes(buffer.readerIndex(), bytes) + packet.release() + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun releaseWorldEntityPacket(infos: Infos) { + val packet = checkNotNull(infos.getPackets().rootWorldInfoPackets.worldEntityInfo.getOrNull()) + packet.consume() + packet.release() + } + } +} diff --git a/protocol/osrs-239/osrs-239-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt b/protocol/osrs-239/osrs-239-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt index 3a795b541..579b4ef11 100644 --- a/protocol/osrs-239/osrs-239-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt +++ b/protocol/osrs-239/osrs-239-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/TestHelpers.kt @@ -16,6 +16,7 @@ import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoProtocol import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarFactory import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerInfoProtocol import net.rsprot.protocol.game.outgoing.info.worker.DefaultProtocolWorker +import net.rsprot.protocol.game.outgoing.info.worker.ProtocolWorker import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatarFactory import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityProtocol import net.rsprot.protocol.internal.client.ClientTypeMap @@ -39,7 +40,18 @@ internal fun generateNpcAvatarFactory( internal fun generateInfoProtocols( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), -): InfoProtocols { +): InfoProtocols = generateInfoProtocolContext(npcAvatarFactory, npcIndexStorage).protocols + +internal data class TestInfoProtocolContext( + val protocols: InfoProtocols, + val worldEntityAvatarFactory: WorldEntityAvatarFactory, +) + +internal fun generateInfoProtocolContext( + npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), + npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +74,19 @@ internal fun generateInfoProtocols( protocolSupplier.supply(npcInfoProtocol) val worldEntityStorage = ZoneIndexStorage(ZoneIndexStorage.WORLDENTITY_CAPACITY) + val worldEntityAvatarFactory = + WorldEntityAvatarFactory( + allocator, + worldEntityStorage, + listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), + DefaultHuffmanCodecProvider(createHuffmanCodec()), + ) val worldEntityInfoProtocol = WorldEntityProtocol( allocator, exceptionHandler = { _, _ -> }, - factory = - WorldEntityAvatarFactory( - allocator, - worldEntityStorage, - listOf(WorldEntityAvatarExtendedInfoDesktopWriter()), - DefaultHuffmanCodecProvider(createHuffmanCodec()), - ), + factory = worldEntityAvatarFactory, zoneIndexStorage = worldEntityStorage, ) @@ -87,13 +100,17 @@ internal fun generateInfoProtocols( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) - return InfoProtocols( - playerInfoProtocol, - npcInfoProtocol, - worldEntityInfoProtocol, + return TestInfoProtocolContext( + protocols = + InfoProtocols( + playerInfoProtocol, + npcInfoProtocol, + worldEntityInfoProtocol, + ), + worldEntityAvatarFactory = worldEntityAvatarFactory, ) } diff --git a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt index 43e032761..09c578914 100644 --- a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt +++ b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfo.kt @@ -305,7 +305,7 @@ public class PlayerInfo internal constructor( if (isDestroyed()) return ArrayList(0) val collection = ArrayList(highResolutionCount) for (i in 0.. -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +869,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,8 +904,10 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -912,6 +922,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,12 +947,22 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player at its current coordinate. + */ + internal fun resolveWorldEntityIndex(): Int = + checkNotNull(worldEntityInfo) { + "World entity info is null" + }.getWorldEntity(avatar.currentCoord) + /** * Allocates a new buffer from the [allocator] with a capacity of [BUF_CAPACITY]. * The old [buffer] will not be released, as that is the duty of the encoder class. @@ -964,11 +986,13 @@ public class PlayerInfo internal constructor( // Only need to reset the count here, the actual numbers don't matter. extendedInfoCount = 0 for (i in 1 until PROTOCOL_CAPACITY) { - stationary[i] = (stationary[i].toInt() shr 1).toByte() + val stationaryState = (stationary[i].toInt() shr 1).toByte() + stationary[i] = stationaryState + val packedIndex = i or ((stationaryState.toInt() and WAS_STATIONARY) shl STATIONARY_INDEX_SHIFT) if (isHighResolution(i)) { - highResolutionIndices[highResolutionCount++] = i.toShort() + highResolutionIndices[highResolutionCount++] = packedIndex.toShort() } else { - lowResolutionIndices[lowResolutionCount++] = i.toShort() + lowResolutionIndices[lowResolutionCount++] = packedIndex.toShort() } } observerExtendedInfoFlags.reset() @@ -1148,6 +1172,11 @@ public class PlayerInfo internal constructor( */ private const val BUF_CAPACITY: Int = 40_000 + // Player indices only use 11 bits. The sign bit stores the WAS_STATIONARY snapshot + // alongside each resolution-list entry so both protocol passes avoid a dependent array read. + private const val INDEX_MASK: Int = 0x7FFF + private const val STATIONARY_INDEX_SHIFT: Int = 15 + /** * The flag indicating that a player was stationary in the previous cycle. */ diff --git a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt index 4dc3ef68e..9e52f95f8 100644 --- a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt +++ b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/playerinfo/PlayerInfoProtocol.kt @@ -47,6 +47,12 @@ public class PlayerInfoProtocol( private val lowResolutionPositionRepository: GlobalLowResolutionPositionRepository = GlobalLowResolutionPositionRepository() + /** + * The world entity index containing each active player for the current protocol cycle. + * This is rebuilt before any player update jobs are submitted to the worker. + */ + private val worldEntityIndices: IntArray = IntArray(PROTOCOL_CAPACITY) { WorldEntityInfo.ROOT_WORLD } + /** * The repository responsible for allocating and storing player info instances of * all the avatars that exist. @@ -138,6 +144,11 @@ public class PlayerInfoProtocol( internal fun getLowResolutionPosition(idx: Int): LowResolutionPosition = lowResolutionPositionRepository.getCurrentLowResolutionPosition(idx) + /** + * Gets the world entity index containing the player at [idx] in the current protocol cycle. + */ + internal fun getWorldEntityIndex(idx: Int): Int = worldEntityIndices[idx] + public fun update() { checkCommunicationThread() prepare() @@ -160,6 +171,7 @@ public class PlayerInfoProtocol( // Synchronize the known low res positions of everyone for this cycle for (i in 1..