*/ private array $resourcePacksById = []; /** @var bool[][] uuid => [chunk index => hasSent] */ private array $downloadedChunks = []; /** @phpstan-var \SplQueue */ private \SplQueue $requestQueue; private int $activeRequests = 0; /** * @param ResourcePack[] $resourcePackStack * @param string[] $encryptionKeys pack UUID => key, leave unset for any packs that are not encrypted * * @phpstan-param list $resourcePackStack * @phpstan-param array $encryptionKeys * @phpstan-param \Closure() : void $completionCallback */ public function __construct( private NetworkSession $session, private array $resourcePackStack, private array $encryptionKeys, private bool $mustAccept, private \Closure $completionCallback ){ $this->requestQueue = new \SplQueue(); foreach($resourcePackStack as $pack){ $this->resourcePacksById[$pack->getPackId()] = $pack; } } private function getPackById(string $id) : ?ResourcePack{ return $this->resourcePacksById[strtolower($id)] ?? null; } public function setUp() : void{ $resourcePackEntries = array_map(function(ResourcePack $pack) : ResourcePackInfoEntry{ //TODO: more stuff return new ResourcePackInfoEntry( $pack->getPackId(), $pack->getPackVersion(), $pack->getPackSize(), $this->encryptionKeys[$pack->getPackId()] ?? "", "", $pack->getPackId(), false ); }, $this->resourcePackStack); //TODO: support forcing server packs $this->session->sendDataPacket(ResourcePacksInfoPacket::create( resourcePackEntries: $resourcePackEntries, behaviorPackEntries: [], mustAccept: $this->mustAccept, hasAddons: false, hasScripts: false, forceServerPacks: false, cdnUrls: [] )); $this->session->getLogger()->debug("Waiting for client to accept resource packs"); } private function disconnectWithError(string $error) : void{ $this->session->disconnectWithError( reason: "Error downloading resource packs: " . $error, disconnectScreenMessage: KnownTranslationFactory::disconnectionScreen_resourcePack() ); } public function handleResourcePackClientResponse(ResourcePackClientResponsePacket $packet) : bool{ switch($packet->status){ case ResourcePackClientResponsePacket::STATUS_REFUSED: //TODO: add lang strings for this $this->session->disconnect("Refused resource packs", "You must accept resource packs to join this server.", true); break; case ResourcePackClientResponsePacket::STATUS_SEND_PACKS: foreach($packet->packIds as $uuid){ //dirty hack for mojang's dirty hack for versions $splitPos = strpos($uuid, "_"); if($splitPos !== false){ $uuid = substr($uuid, 0, $splitPos); } $pack = $this->getPackById($uuid); if(!($pack instanceof ResourcePack)){ //Client requested a resource pack but we don't have it available on the server $this->disconnectWithError("Unknown pack $uuid requested, available packs: " . implode(", ", array_keys($this->resourcePacksById))); return false; } $this->session->sendDataPacket(ResourcePackDataInfoPacket::create( $pack->getPackId(), self::PACK_CHUNK_SIZE, (int) ceil($pack->getPackSize() / self::PACK_CHUNK_SIZE), $pack->getPackSize(), $pack->getSha256(), false, ResourcePackType::RESOURCES //TODO: this might be an addon (not behaviour pack), needed to properly support client-side custom items )); } $this->session->getLogger()->debug("Player requested download of " . count($packet->packIds) . " resource packs"); break; case ResourcePackClientResponsePacket::STATUS_HAVE_ALL_PACKS: $stack = array_map(static function(ResourcePack $pack) : ResourcePackStackEntry{ return new ResourcePackStackEntry($pack->getPackId(), $pack->getPackVersion(), ""); //TODO: subpacks }, $this->resourcePackStack); //we support chemistry blocks by default, the client should already have this installed $stack[] = new ResourcePackStackEntry("0fba4063-dba1-4281-9b89-ff9390653530", "1.0.0", ""); //we don't force here, because it doesn't have user-facing effects //but it does have an annoying side-effect when true: it makes //the client remove its own non-server-supplied resource packs. $this->session->sendDataPacket(ResourcePackStackPacket::create($stack, [], false, ProtocolInfo::MINECRAFT_VERSION_NETWORK, new Experiments([], false))); $this->session->getLogger()->debug("Applying resource pack stack"); break; case ResourcePackClientResponsePacket::STATUS_COMPLETED: $this->session->getLogger()->debug("Resource packs sequence completed"); ($this->completionCallback)(); break; default: return false; } return true; } public function handleResourcePackChunkRequest(ResourcePackChunkRequestPacket $packet) : bool{ $pack = $this->getPackById($packet->packId); if(!($pack instanceof ResourcePack)){ $this->disconnectWithError("Invalid request for chunk $packet->chunkIndex of unknown pack $packet->packId, available packs: " . implode(", ", array_keys($this->resourcePacksById))); return false; } $packId = $pack->getPackId(); //use this because case may be different if(isset($this->downloadedChunks[$packId][$packet->chunkIndex])){ $this->disconnectWithError("Duplicate request for chunk $packet->chunkIndex of pack $packet->packId"); return false; } $offset = $packet->chunkIndex * self::PACK_CHUNK_SIZE; if($offset < 0 || $offset >= $pack->getPackSize()){ $this->disconnectWithError("Invalid out-of-bounds request for chunk $packet->chunkIndex of $packet->packId: offset $offset, file size " . $pack->getPackSize()); return false; } if(!isset($this->downloadedChunks[$packId])){ $this->downloadedChunks[$packId] = [$packet->chunkIndex => true]; }else{ $this->downloadedChunks[$packId][$packet->chunkIndex] = true; } $this->requestQueue->enqueue([$pack, $packet->chunkIndex]); $this->processChunkRequestQueue(); return true; } private function processChunkRequestQueue() : void{ if($this->activeRequests >= self::MAX_CONCURRENT_CHUNK_REQUESTS || $this->requestQueue->isEmpty()){ return; } /** * @var ResourcePack $pack * @var int $chunkIndex */ [$pack, $chunkIndex] = $this->requestQueue->dequeue(); $packId = $pack->getPackId(); $offset = $chunkIndex * self::PACK_CHUNK_SIZE; $chunkData = $pack->getPackChunk($offset, self::PACK_CHUNK_SIZE); $this->activeRequests++; $this->session ->sendDataPacketWithReceipt(ResourcePackChunkDataPacket::create($packId, $chunkIndex, $offset, $chunkData)) ->onCompletion( function() : void{ $this->activeRequests--; $this->processChunkRequestQueue(); }, function() : void{ //this may have been rejected because of a disconnection - this will do nothing in that case $this->disconnectWithError("Plugin interrupted sending of resource packs"); } ); } }