From 924f5f5a944ba5af0a846fc3d5b8e225da5d3666 Mon Sep 17 00:00:00 2001 From: Khaled Jaber Date: Thu, 16 Jul 2026 22:23:01 -0400 Subject: [PATCH 1/4] perf: reuse player source world membership --- .../outgoing/info/playerinfo/PlayerInfo.kt | 47 ++-- .../info/worldentityinfo/WorldEntityInfo.kt | 13 + .../outgoing/info/playerinfo/PlayerInfo.kt | 47 ++-- .../info/worldentityinfo/WorldEntityInfo.kt | 13 + .../outgoing/info/InfoBenchmarkProtocols.kt | 17 +- .../game/outgoing/info/PlayerInfoBenchmark.kt | 118 ++++++-- .../info/PlayerInfoWorldMembershipTest.kt | 263 ++++++++++++++++++ .../game/outgoing/info/TestHelpers.kt | 39 ++- .../outgoing/info/playerinfo/PlayerInfo.kt | 47 ++-- .../info/worldentityinfo/WorldEntityInfo.kt | 13 + .../outgoing/info/playerinfo/PlayerInfo.kt | 47 ++-- .../info/worldentityinfo/WorldEntityInfo.kt | 13 + .../outgoing/info/playerinfo/PlayerInfo.kt | 47 ++-- .../info/worldentityinfo/WorldEntityInfo.kt | 13 + 14 files changed, 619 insertions(+), 118 deletions(-) create mode 100644 protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt 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..b4d8809e1 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 @@ -581,12 +581,23 @@ public class PlayerInfo internal constructor( */ internal fun pBitcodes() { avatar.resize(highResolutionCount) + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val sourceCoord = avatar.currentCoord + val sourceWorldIndex = + if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { + WorldEntityInfo.ROOT_WORLD + } else { + worldEntityInfo.getWorldEntity(sourceCoord) + } val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() - bitBuf.use { processHighResolution(it, skipStationary = true) } - bitBuf.use { processHighResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } } /** @@ -596,13 +607,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processLowResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until lowResolutionCount) { val index = lowResolutionIndices[i].toInt() @@ -626,7 +636,7 @@ public class PlayerInfo internal constructor( stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() continue } - val visible = shouldMoveToHighResolution(worldEntityInfo, other) + val visible = shouldMoveToHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other) if (!visible && lowResolutionMovementBuffer == null) { skips++ stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() @@ -702,13 +712,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processHighResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until highResolutionCount) { val index = highResolutionIndices[i].toInt() @@ -717,7 +726,7 @@ public class PlayerInfo internal constructor( continue } val other = protocol.getPlayerInfo(index) - if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (!shouldStayInHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other)) { if (skips > -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +872,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,7 +907,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) @@ -912,6 +924,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,7 +949,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) diff --git a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 5ddf63b21..a6f72a806 100644 --- a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -580,6 +580,19 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) + return isVisible(source, sourceWorldIndex, target, radius) + } + + /** + * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. + * The source world index may be reused for every target observed by the same player update. + */ + internal fun isVisible( + source: CoordGrid, + sourceWorldIndex: Int, + target: CoordGrid, + radius: Int, + ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) // If both parties are in the root world, just run the usual checks if (sourceWorldIndex == ROOT_WORLD && targetWorldIndex == ROOT_WORLD) { 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..b4d8809e1 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 @@ -581,12 +581,23 @@ public class PlayerInfo internal constructor( */ internal fun pBitcodes() { avatar.resize(highResolutionCount) + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val sourceCoord = avatar.currentCoord + val sourceWorldIndex = + if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { + WorldEntityInfo.ROOT_WORLD + } else { + worldEntityInfo.getWorldEntity(sourceCoord) + } val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() - bitBuf.use { processHighResolution(it, skipStationary = true) } - bitBuf.use { processHighResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } } /** @@ -596,13 +607,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processLowResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until lowResolutionCount) { val index = lowResolutionIndices[i].toInt() @@ -626,7 +636,7 @@ public class PlayerInfo internal constructor( stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() continue } - val visible = shouldMoveToHighResolution(worldEntityInfo, other) + val visible = shouldMoveToHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other) if (!visible && lowResolutionMovementBuffer == null) { skips++ stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() @@ -702,13 +712,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processHighResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until highResolutionCount) { val index = highResolutionIndices[i].toInt() @@ -717,7 +726,7 @@ public class PlayerInfo internal constructor( continue } val other = protocol.getPlayerInfo(index) - if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (!shouldStayInHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other)) { if (skips > -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +872,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,7 +907,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) @@ -912,6 +924,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,7 +949,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 21f587853..b0b7c341f 100644 --- a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,6 +590,19 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) + return isVisible(source, sourceWorldIndex, target, radius) + } + + /** + * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. + * The source world index may be reused for every target observed by the same player update. + */ + internal fun isVisible( + source: CoordGrid, + sourceWorldIndex: Int, + target: CoordGrid, + radius: Int, + ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) // If both parties are in the root world, just run the usual checks if (sourceWorldIndex == ROOT_WORLD && targetWorldIndex == ROOT_WORLD) { diff --git a/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt b/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt index ee05bf89f..ff0592db7 100644 --- a/protocol/osrs-237/osrs-237-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt +++ b/protocol/osrs-237/osrs-237-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-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..05dbea89f 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,102 @@ 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) + @Param + private lateinit var scenario: Scenario + @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( + level = 0, + 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(0, x, z, x, z) + } + Scenario.MIXED_WORLD_ENTITIES -> { + if (index <= ROOT_PLAYER_COUNT) { + PlayerPosition(0, 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( + level = 0, + 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,16 +161,7 @@ class PlayerInfoBenchmark { private fun tick() { for (i in 1..() + + 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() + + assertEquals(EXPECTED_PACKET_HEX, packets) + } + + private class PlayerFixture { + private val context = generateInfoProtocolContext() + 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, + ): WorldEntityAvatar = allocWorld(context.worldEntityAvatarFactory, index, zoneX, activeLevel, projectedX) + + fun releaseWorld(world: WorldEntityAvatar) { + context.worldEntityAvatarFactory.release(world) + } + + fun reallocateTarget() { + protocols.dealloc(target) + target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) + updateTarget(0, ROOT_X + 1, ROOT_Z) + 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 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", + ) + + 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, + activeLevel: Int, + projectedX: Int, + ): WorldEntityAvatar = + factory.alloc( + index = index, + id = index, + ownerIndex = 0, + sizeX = 1, + sizeZ = 1, + southWestZoneX = zoneX, + southWestZoneZ = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + 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..eb709c297 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 @@ -39,7 +39,17 @@ 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), +): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() val encoders = @@ -62,18 +72,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, ) @@ -90,10 +101,14 @@ internal fun generateInfoProtocols( DefaultProtocolWorker(), 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..b6805b347 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 @@ -581,12 +581,23 @@ public class PlayerInfo internal constructor( */ internal fun pBitcodes() { avatar.resize(highResolutionCount) + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val sourceCoord = avatar.currentCoord + val sourceWorldIndex = + if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { + WorldEntityInfo.ROOT_WORLD + } else { + worldEntityInfo.getWorldEntity(sourceCoord) + } val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() - bitBuf.use { processHighResolution(it, skipStationary = true) } - bitBuf.use { processHighResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } } /** @@ -596,13 +607,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processLowResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until lowResolutionCount) { val index = lowResolutionIndices[i].toInt() @@ -626,7 +636,7 @@ public class PlayerInfo internal constructor( stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() continue } - val visible = shouldMoveToHighResolution(worldEntityInfo, other) + val visible = shouldMoveToHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other) if (!visible && lowResolutionMovementBuffer == null) { skips++ stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() @@ -702,13 +712,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processHighResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until highResolutionCount) { val index = highResolutionIndices[i].toInt() @@ -717,7 +726,7 @@ public class PlayerInfo internal constructor( continue } val other = protocol.getPlayerInfo(index) - if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (!shouldStayInHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other)) { if (skips > -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +872,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,7 +907,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) @@ -912,6 +924,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,7 +949,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) diff --git a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 4f176417c..71142b433 100644 --- a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,6 +590,19 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) + return isVisible(source, sourceWorldIndex, target, radius) + } + + /** + * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. + * The source world index may be reused for every target observed by the same player update. + */ + internal fun isVisible( + source: CoordGrid, + sourceWorldIndex: Int, + target: CoordGrid, + radius: Int, + ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) // If both parties are in the root world, just run the usual checks if (sourceWorldIndex == ROOT_WORLD && targetWorldIndex == ROOT_WORLD) { 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..b6805b347 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 @@ -581,12 +581,23 @@ public class PlayerInfo internal constructor( */ internal fun pBitcodes() { avatar.resize(highResolutionCount) + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val sourceCoord = avatar.currentCoord + val sourceWorldIndex = + if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { + WorldEntityInfo.ROOT_WORLD + } else { + worldEntityInfo.getWorldEntity(sourceCoord) + } val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() - bitBuf.use { processHighResolution(it, skipStationary = true) } - bitBuf.use { processHighResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } } /** @@ -596,13 +607,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processLowResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until lowResolutionCount) { val index = lowResolutionIndices[i].toInt() @@ -626,7 +636,7 @@ public class PlayerInfo internal constructor( stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() continue } - val visible = shouldMoveToHighResolution(worldEntityInfo, other) + val visible = shouldMoveToHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other) if (!visible && lowResolutionMovementBuffer == null) { skips++ stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() @@ -702,13 +712,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processHighResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until highResolutionCount) { val index = highResolutionIndices[i].toInt() @@ -717,7 +726,7 @@ public class PlayerInfo internal constructor( continue } val other = protocol.getPlayerInfo(index) - if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (!shouldStayInHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other)) { if (skips > -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +872,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,7 +907,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) @@ -912,6 +924,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,7 +949,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) diff --git a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 53ff6222f..4bec0dfeb 100644 --- a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,6 +590,19 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) + return isVisible(source, sourceWorldIndex, target, radius) + } + + /** + * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. + * The source world index may be reused for every target observed by the same player update. + */ + internal fun isVisible( + source: CoordGrid, + sourceWorldIndex: Int, + target: CoordGrid, + radius: Int, + ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) // If both parties are in the root world, just run the usual checks if (sourceWorldIndex == ROOT_WORLD && targetWorldIndex == ROOT_WORLD) { 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..b6805b347 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 @@ -581,12 +581,23 @@ public class PlayerInfo internal constructor( */ internal fun pBitcodes() { avatar.resize(highResolutionCount) + val worldEntityInfo = + checkNotNull(this.worldEntityInfo) { + "World entity info is null" + } + val sourceCoord = avatar.currentCoord + val sourceWorldIndex = + if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { + WorldEntityInfo.ROOT_WORLD + } else { + worldEntityInfo.getWorldEntity(sourceCoord) + } val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() - bitBuf.use { processHighResolution(it, skipStationary = true) } - bitBuf.use { processHighResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = false) } - bitBuf.use { processLowResolution(it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } + bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = false) } + bitBuf.use { processLowResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } } /** @@ -596,13 +607,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processLowResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until lowResolutionCount) { val index = lowResolutionIndices[i].toInt() @@ -626,7 +636,7 @@ public class PlayerInfo internal constructor( stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() continue } - val visible = shouldMoveToHighResolution(worldEntityInfo, other) + val visible = shouldMoveToHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other) if (!visible && lowResolutionMovementBuffer == null) { skips++ stationary[index] = (stationary[index].toInt() or IS_STATIONARY).toByte() @@ -702,13 +712,12 @@ public class PlayerInfo internal constructor( * @param skipStationary whether to skip any players who were marked as stationary last cycle. */ private fun processHighResolution( + worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, buffer: BitBuf, skipStationary: Boolean, ) { - val worldEntityInfo = - checkNotNull(this.worldEntityInfo) { - "World entity info is null" - } var skips = -1 for (i in 0 until highResolutionCount) { val index = highResolutionIndices[i].toInt() @@ -717,7 +726,7 @@ public class PlayerInfo internal constructor( continue } val other = protocol.getPlayerInfo(index) - if (!shouldStayInHighResolution(worldEntityInfo, other)) { + if (!shouldStayInHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, other)) { if (skips > -1) { pStationary(buffer, skips) skips = -1 @@ -863,6 +872,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldStayInHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -896,7 +907,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) @@ -912,6 +924,8 @@ public class PlayerInfo internal constructor( @OptIn(ExperimentalContracts::class) private fun shouldMoveToHighResolution( worldEntityInfo: WorldEntityInfo, + sourceCoord: CoordGrid, + sourceWorldIndex: Int, other: PlayerInfo?, ): Boolean { contract { @@ -935,7 +949,8 @@ public class PlayerInfo internal constructor( } return rangeToCheck == Int.MAX_VALUE || worldEntityInfo.isVisible( - avatar.currentCoord, + sourceCoord, + sourceWorldIndex, otherCoordGrid, rangeToCheck, ) diff --git a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 7aaa8fcbd..2c5106ef9 100644 --- a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -591,6 +591,19 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) + return isVisible(source, sourceWorldIndex, target, radius) + } + + /** + * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. + * The source world index may be reused for every target observed by the same player update. + */ + internal fun isVisible( + source: CoordGrid, + sourceWorldIndex: Int, + target: CoordGrid, + radius: Int, + ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) // If both parties are in the root world, just run the usual checks if (sourceWorldIndex == ROOT_WORLD && targetWorldIndex == ROOT_WORLD) { From cc785ce4b776479ef3ccb7fb3cffdab4bba98089 Mon Sep 17 00:00:00 2001 From: Khaled Jaber Date: Thu, 16 Jul 2026 22:52:18 -0400 Subject: [PATCH 2/4] perf: precompute player world membership --- .../outgoing/info/playerinfo/PlayerInfo.kt | 17 +- .../info/playerinfo/PlayerInfoProtocol.kt | 18 ++- .../info/worldentityinfo/WorldEntityInfo.kt | 14 ++ .../outgoing/info/playerinfo/PlayerInfo.kt | 17 +- .../info/playerinfo/PlayerInfoProtocol.kt | 18 ++- .../info/worldentityinfo/WorldEntityInfo.kt | 14 ++ .../info/PlayerInfoWorldMembershipTest.kt | 148 +++++++++++++++++- .../game/outgoing/info/TestHelpers.kt | 4 +- .../outgoing/info/playerinfo/PlayerInfo.kt | 17 +- .../info/playerinfo/PlayerInfoProtocol.kt | 18 ++- .../info/worldentityinfo/WorldEntityInfo.kt | 14 ++ .../outgoing/info/playerinfo/PlayerInfo.kt | 17 +- .../info/playerinfo/PlayerInfoProtocol.kt | 18 ++- .../info/worldentityinfo/WorldEntityInfo.kt | 14 ++ .../outgoing/info/playerinfo/PlayerInfo.kt | 17 +- .../info/playerinfo/PlayerInfoProtocol.kt | 18 ++- .../info/worldentityinfo/WorldEntityInfo.kt | 14 ++ 17 files changed, 355 insertions(+), 42 deletions(-) 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 b4d8809e1..7edeb0b17 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 @@ -586,12 +586,7 @@ public class PlayerInfo internal constructor( "World entity info is null" } val sourceCoord = avatar.currentCoord - val sourceWorldIndex = - if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { - WorldEntityInfo.ROOT_WORLD - } else { - worldEntityInfo.getWorldEntity(sourceCoord) - } + val sourceWorldIndex = protocol.getWorldEntityIndex(localIndex) val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } @@ -910,6 +905,7 @@ public class PlayerInfo internal constructor( sourceCoord, sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -952,10 +948,19 @@ public class PlayerInfo internal constructor( sourceCoord, sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player for the current protocol cycle. + */ + 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. 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..() + + 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() + + assertEquals(EXPECTED_PACKET_HEX, 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 @@ -155,16 +288,18 @@ class PlayerInfoWorldMembershipTest { zoneX: Int, activeLevel: Int, projectedX: Int, - ): WorldEntityAvatar = allocWorld(context.worldEntityAvatarFactory, index, zoneX, activeLevel, projectedX) + 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() { + fun reallocateTarget(coord: CoordGrid = CoordGrid(0, ROOT_X + 1, ROOT_Z)) { protocols.dealloc(target) target = protocols.alloc(TARGET_INDEX, OldSchoolClientType.DESKTOP) - updateTarget(0, ROOT_X + 1, ROOT_Z) + updateTarget(coord) initializeAppearance(target.playerInfo) } @@ -213,6 +348,7 @@ class PlayerInfoWorldMembershipTest { factory: WorldEntityAvatarFactory, index: Int, zoneX: Int, + zoneZ: Int, activeLevel: Int, projectedX: Int, ): WorldEntityAvatar = @@ -223,7 +359,7 @@ class PlayerInfoWorldMembershipTest { sizeX = 1, sizeZ = 1, southWestZoneX = zoneX, - southWestZoneZ = if (zoneX == ROOT_X ushr 3) ROOT_Z ushr 3 else INSTANCE_ZONE_Z, + southWestZoneZ = zoneZ, minLevel = 0, maxLevel = 1, fineX = projectedX * 128 + 64, 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 eb709c297..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 @@ -49,6 +50,7 @@ internal data class TestInfoProtocolContext( internal fun generateInfoProtocolContext( npcAvatarFactory: NpcAvatarFactory = generateNpcAvatarFactory(), npcIndexStorage: ZoneIndexStorage = ZoneIndexStorage(ZoneIndexStorage.NPC_CAPACITY), + playerProtocolWorker: ProtocolWorker = DefaultProtocolWorker(), ): TestInfoProtocolContext { val allocator = UnpooledByteBufAllocator.DEFAULT val protocolSupplier = DeferredNpcInfoProtocolSupplier() @@ -98,7 +100,7 @@ internal fun generateInfoProtocolContext( val playerInfoProtocol = PlayerInfoProtocol( allocator, - DefaultProtocolWorker(), + playerProtocolWorker, playerAvatarFactory, ) return TestInfoProtocolContext( 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 b6805b347..b5fb2340a 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 @@ -586,12 +586,7 @@ public class PlayerInfo internal constructor( "World entity info is null" } val sourceCoord = avatar.currentCoord - val sourceWorldIndex = - if (avatar.resizeRange == Int.MAX_VALUE && avatar.preferredResizeRange == Int.MAX_VALUE) { - WorldEntityInfo.ROOT_WORLD - } else { - worldEntityInfo.getWorldEntity(sourceCoord) - } + val sourceWorldIndex = protocol.getWorldEntityIndex(localIndex) val buffer = allocBuffer() val bitBuf = buffer.toBitBuf() bitBuf.use { processHighResolution(worldEntityInfo, sourceCoord, sourceWorldIndex, it, skipStationary = true) } @@ -910,6 +905,7 @@ public class PlayerInfo internal constructor( sourceCoord, sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } @@ -952,10 +948,19 @@ public class PlayerInfo internal constructor( sourceCoord, sourceWorldIndex, otherCoordGrid, + protocol.getWorldEntityIndex(other.localIndex), rangeToCheck, ) } + /** + * Resolves the world entity containing this player for the current protocol cycle. + */ + 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. 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.. Date: Fri, 17 Jul 2026 01:46:50 -0400 Subject: [PATCH 3/4] perf: pack player stationary state into indices --- .../outgoing/info/playerinfo/PlayerInfo.kt | 27 ++++-- .../outgoing/info/playerinfo/PlayerInfo.kt | 27 ++++-- .../game/outgoing/info/PlayerInfoBenchmark.kt | 20 ++++- .../info/PlayerInfoWorldMembershipTest.kt | 84 +++++++++++++++++++ .../outgoing/info/playerinfo/PlayerInfo.kt | 27 ++++-- .../outgoing/info/playerinfo/PlayerInfo.kt | 27 ++++-- .../outgoing/info/playerinfo/PlayerInfo.kt | 27 ++++-- 7 files changed, 193 insertions(+), 46 deletions(-) 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 7edeb0b17..7ab5fcf7a 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..(highResolutionCount) for (i in 0.. 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() { val context = @@ -161,8 +165,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() @@ -316,6 +342,50 @@ class PlayerInfoWorldMembershipTest { 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 @@ -338,6 +408,20 @@ class PlayerInfoWorldMembershipTest { "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, 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 b5fb2340a..af1f83500 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..(highResolutionCount) for (i in 0..(highResolutionCount) for (i in 0.. Date: Fri, 17 Jul 2026 22:29:37 -0400 Subject: [PATCH 4/4] test: tighten player info performance coverage --- .../outgoing/info/InfoBenchmarkProtocols.kt | 17 +- .../game/outgoing/info/PlayerInfoBenchmark.kt | 133 ++++- .../game/outgoing/info/PlayerInfoTest.kt | 5 + .../info/PlayerInfoWorldMembershipTest.kt | 462 ++++++++++++++++++ .../game/outgoing/info/TestHelpers.kt | 43 +- .../outgoing/info/playerinfo/PlayerInfo.kt | 2 +- .../info/worldentityinfo/WorldEntityInfo.kt | 13 - .../outgoing/info/InfoBenchmarkProtocols.kt | 17 +- .../game/outgoing/info/PlayerInfoBenchmark.kt | 133 ++++- .../game/outgoing/info/PlayerInfoTest.kt | 5 + .../info/PlayerInfoWorldMembershipTest.kt | 462 ++++++++++++++++++ .../game/outgoing/info/TestHelpers.kt | 43 +- .../outgoing/info/playerinfo/PlayerInfo.kt | 2 +- .../info/worldentityinfo/WorldEntityInfo.kt | 13 - .../game/outgoing/info/PlayerInfoBenchmark.kt | 11 +- .../game/outgoing/info/PlayerInfoTest.kt | 5 + .../info/PlayerInfoWorldMembershipTest.kt | 91 ++-- .../outgoing/info/playerinfo/PlayerInfo.kt | 2 +- .../info/worldentityinfo/WorldEntityInfo.kt | 13 - .../outgoing/info/InfoBenchmarkProtocols.kt | 17 +- .../game/outgoing/info/PlayerInfoBenchmark.kt | 133 ++++- .../game/outgoing/info/PlayerInfoTest.kt | 5 + .../info/PlayerInfoWorldMembershipTest.kt | 462 ++++++++++++++++++ .../game/outgoing/info/TestHelpers.kt | 43 +- .../outgoing/info/playerinfo/PlayerInfo.kt | 2 +- .../info/worldentityinfo/WorldEntityInfo.kt | 13 - .../outgoing/info/InfoBenchmarkProtocols.kt | 17 +- .../game/outgoing/info/PlayerInfoBenchmark.kt | 133 ++++- .../game/outgoing/info/PlayerInfoTest.kt | 5 + .../info/PlayerInfoWorldMembershipTest.kt | 462 ++++++++++++++++++ .../game/outgoing/info/TestHelpers.kt | 43 +- .../outgoing/info/playerinfo/PlayerInfo.kt | 2 +- .../info/worldentityinfo/WorldEntityInfo.kt | 13 - 33 files changed, 2533 insertions(+), 289 deletions(-) create mode 100644 protocol/osrs-235/osrs-235-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt create mode 100644 protocol/osrs-236/osrs-236-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt create mode 100644 protocol/osrs-238/osrs-238-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt create mode 100644 protocol/osrs-239/osrs-239-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt 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 7ab5fcf7a..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 @@ -956,7 +956,7 @@ public class PlayerInfo internal constructor( } /** - * Resolves the world entity containing this player for the current protocol cycle. + * Resolves the world entity containing this player at its current coordinate. */ internal fun resolveWorldEntityIndex(): Int = checkNotNull(worldEntityInfo) { diff --git a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index fc3d95543..3aabf8090 100644 --- a/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-235/osrs-235-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -580,19 +580,6 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) - return isVisible(source, sourceWorldIndex, target, radius) - } - - /** - * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. - * The source world index may be reused for every target observed by the same player update. - */ - internal fun isVisible( - source: CoordGrid, - sourceWorldIndex: Int, - target: CoordGrid, - radius: Int, - ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) return isVisible(source, sourceWorldIndex, target, targetWorldIndex, radius) } diff --git a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt b/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt index ee05bf89f..ff0592db7 100644 --- a/protocol/osrs-236/osrs-236-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt +++ b/protocol/osrs-236/osrs-236-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-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 7ab5fcf7a..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 @@ -956,7 +956,7 @@ public class PlayerInfo internal constructor( } /** - * Resolves the world entity containing this player for the current protocol cycle. + * Resolves the world entity containing this player at its current coordinate. */ internal fun resolveWorldEntityIndex(): Int = checkNotNull(worldEntityInfo) { diff --git a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 7016eebb0..5083259d1 100644 --- a/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-236/osrs-236-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,19 +590,6 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) - return isVisible(source, sourceWorldIndex, target, radius) - } - - /** - * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. - * The source world index may be reused for every target observed by the same player update. - */ - internal fun isVisible( - source: CoordGrid, - sourceWorldIndex: Int, - target: CoordGrid, - radius: Int, - ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) return isVisible(source, sourceWorldIndex, target, targetWorldIndex, radius) } 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 6ba0fb78a..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 @@ -93,7 +93,7 @@ class PlayerInfoBenchmark { infos: Infos, position: PlayerPosition, ) { - infos.updateRootCoord(position.level, position.x, position.z) + infos.updateRootCoord(0, position.x, position.z) infos.updateRootBuildAreaCenteredOnPlayer(position.buildAreaCenterX, position.buildAreaCenterZ) } @@ -101,7 +101,6 @@ class PlayerInfoBenchmark { when (scenario) { Scenario.DENSE_ROOT -> PlayerPosition( - level = 0, x = ROOT_X + random.nextInt(8), z = ROOT_Z + random.nextInt(8), buildAreaCenterX = ROOT_X, @@ -110,15 +109,14 @@ class PlayerInfoBenchmark { Scenario.DISTRIBUTED_ROOT -> { val x = ROOT_X + (index % 50) * 32 val z = ROOT_Z + (index / 50) * 32 - PlayerPosition(0, x, z, x, z) + PlayerPosition(x, z, x, z) } Scenario.MIXED_WORLD_ENTITIES -> { if (index <= ROOT_PLAYER_COUNT) { - PlayerPosition(0, ROOT_X + index % 8, ROOT_Z + index / 8 % 8, ROOT_X, ROOT_Z) + 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( - level = 0, x = (INSTANCE_ZONE_X + worldIndex) * 8 + index % 8, z = INSTANCE_ZONE_Z * 8 + index / 8 % 8, buildAreaCenterX = ROOT_X + worldIndex * 2, @@ -172,7 +170,7 @@ class PlayerInfoBenchmark { } else { position.x } - infos.updateRootCoord(position.level, x, position.z) + infos.updateRootCoord(0, x, position.z) infos.updateRootBuildAreaCenteredOnPlayer(position.buildAreaCenterX, position.buildAreaCenterZ) } tickCycle++ @@ -201,7 +199,6 @@ class PlayerInfoBenchmark { } private data class PlayerPosition( - val level: Int, val x: Int, val z: Int, val buildAreaCenterX: Int, diff --git a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt index 1b0ddb0fb..f50852667 100644 --- a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt +++ b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoTest.kt @@ -94,6 +94,11 @@ class PlayerInfoTest { otherPlayer.updateRootCoord(0, 3205, 3220) } tick() + val expectedHighResolutionIndices = otherPlayerIndices.toList() + LOCAL_PLAYER_INDEX + assertEquals(expectedHighResolutionIndices, localPlayerInfo.getHighResolutionIndices()) + val appendedHighResolutionIndices = mutableListOf(-1) + localPlayerInfo.appendHighResolutionIndices(appendedHighResolutionIndices) + assertEquals(listOf(-1) + expectedHighResolutionIndices, appendedHighResolutionIndices) assertAllCoordsEqual(otherPlayers) for (player in otherPlayers.filterNotNull()) { player.updateRootCoord(0, 3204, 3220) diff --git a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt index c547ec979..df34407fe 100644 --- a/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt +++ b/protocol/osrs-237/osrs-237-desktop/src/test/kotlin/net/rsprot/protocol/game/outgoing/info/PlayerInfoWorldMembershipTest.kt @@ -84,34 +84,7 @@ class PlayerInfoWorldMembershipTest { @Test fun `player info packet bytes match baseline across membership transitions`() { - val fixture = PlayerFixture() - 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() - - assertEquals(EXPECTED_PACKET_HEX, packets) + assertEquals(EXPECTED_PACKET_HEX, membershipTransitionPackets()) } @Test @@ -177,37 +150,43 @@ class PlayerInfoWorldMembershipTest { @Test fun `parallel observer updates retain baseline packet bytes`() { repeat(10) { - val fixture = PlayerFixture(ForkJoinMultiThreadProtocolWorker()) - 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() - - assertEquals(EXPECTED_PACKET_HEX, packets) + 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() 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 af1f83500..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 @@ -956,7 +956,7 @@ public class PlayerInfo internal constructor( } /** - * Resolves the world entity containing this player for the current protocol cycle. + * Resolves the world entity containing this player at its current coordinate. */ internal fun resolveWorldEntityIndex(): Int = checkNotNull(worldEntityInfo) { diff --git a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index af220d4fc..d2db52de0 100644 --- a/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-237/osrs-237-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,19 +590,6 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) - return isVisible(source, sourceWorldIndex, target, radius) - } - - /** - * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. - * The source world index may be reused for every target observed by the same player update. - */ - internal fun isVisible( - source: CoordGrid, - sourceWorldIndex: Int, - target: CoordGrid, - radius: Int, - ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) return isVisible(source, sourceWorldIndex, target, targetWorldIndex, radius) } diff --git a/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt b/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt index ee05bf89f..ff0592db7 100644 --- a/protocol/osrs-238/osrs-238-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt +++ b/protocol/osrs-238/osrs-238-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-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 af1f83500..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 @@ -956,7 +956,7 @@ public class PlayerInfo internal constructor( } /** - * Resolves the world entity containing this player for the current protocol cycle. + * Resolves the world entity containing this player at its current coordinate. */ internal fun resolveWorldEntityIndex(): Int = checkNotNull(worldEntityInfo) { diff --git a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index 53d9e2471..0ba404c5c 100644 --- a/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-238/osrs-238-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -590,19 +590,6 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) - return isVisible(source, sourceWorldIndex, target, radius) - } - - /** - * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. - * The source world index may be reused for every target observed by the same player update. - */ - internal fun isVisible( - source: CoordGrid, - sourceWorldIndex: Int, - target: CoordGrid, - radius: Int, - ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) return isVisible(source, sourceWorldIndex, target, targetWorldIndex, radius) } diff --git a/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt b/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt index ee05bf89f..ff0592db7 100644 --- a/protocol/osrs-239/osrs-239-desktop/src/benchmarks/kotlin/net/rsprot/protocol/game/outgoing/info/InfoBenchmarkProtocols.kt +++ b/protocol/osrs-239/osrs-239-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-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 af1f83500..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 @@ -956,7 +956,7 @@ public class PlayerInfo internal constructor( } /** - * Resolves the world entity containing this player for the current protocol cycle. + * Resolves the world entity containing this player at its current coordinate. */ internal fun resolveWorldEntityIndex(): Int = checkNotNull(worldEntityInfo) { diff --git a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt index cae3a6257..9e923b5ea 100644 --- a/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt +++ b/protocol/osrs-239/osrs-239-model/src/main/kotlin/net/rsprot/protocol/game/outgoing/info/worldentityinfo/WorldEntityInfo.kt @@ -591,19 +591,6 @@ public class WorldEntityInfo internal constructor( radius: Int, ): Boolean { val sourceWorldIndex = avatarRepository.getByCoordGrid(source) - return isVisible(source, sourceWorldIndex, target, radius) - } - - /** - * Checks if the [target] is visible to the [source] with a precomputed [sourceWorldIndex]. - * The source world index may be reused for every target observed by the same player update. - */ - internal fun isVisible( - source: CoordGrid, - sourceWorldIndex: Int, - target: CoordGrid, - radius: Int, - ): Boolean { val targetWorldIndex = avatarRepository.getByCoordGrid(target) return isVisible(source, sourceWorldIndex, target, targetWorldIndex, radius) }