PocketMine-MP 5.35.1 git-f412a390b8f63d0311cc1d1c81046404153b8440
Loading...
Searching...
No Matches
NetworkSession.php
1<?php
2
3/*
4 *
5 * ____ _ _ __ __ _ __ __ ____
6 * | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8 * | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9 * |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * @author PocketMine Team
17 * @link http://www.pocketmine.net/
18 *
19 *
20 */
21
22declare(strict_types=1);
23
24namespace pocketmine\network\mcpe;
25
26use pmmp\encoding\ByteBufferReader;
27use pmmp\encoding\ByteBufferWriter;
28use pmmp\encoding\DataDecodeException;
104use pocketmine\player\GameMode;
107use pocketmine\player\UsedChunkStatus;
120use function array_filter;
121use function array_map;
122use function array_values;
123use function base64_encode;
124use function bin2hex;
125use function count;
126use function get_class;
127use function implode;
128use function is_string;
129use function json_encode;
130use function ord;
131use function random_bytes;
132use function str_split;
133use function strcasecmp;
134use function strlen;
135use function strtolower;
136use function substr;
137use function time;
138use function ucfirst;
139use const JSON_THROW_ON_ERROR;
140
142 private const INCOMING_PACKET_BATCH_PER_TICK = 2; //usually max 1 per tick, but transactions arrive separately
143 private const INCOMING_PACKET_BATCH_BUFFER_TICKS = 100; //enough to account for a 5-second lag spike
144
145 private const INCOMING_GAME_PACKETS_PER_TICK = 2;
146 private const INCOMING_GAME_PACKETS_BUFFER_TICKS = 100;
147
148 private PacketRateLimiter $packetBatchLimiter;
149 private PacketRateLimiter $gamePacketLimiter;
150
151 private \PrefixedLogger $logger;
152 private ?Player $player = null;
153 private ?PlayerInfo $info = null;
154 private ?int $ping = null;
155
156 private ?PacketHandler $handler = null;
157
158 private bool $connected = true;
159 private bool $disconnectGuard = false;
160 private bool $loggedIn = false;
161 private bool $authenticated = false;
162 private int $connectTime;
163 private ?CompoundTag $cachedOfflinePlayerData = null;
164
165 private ?EncryptionContext $cipher = null;
166
171 private array $sendBuffer = [];
176 private array $sendBufferAckPromises = [];
177
179 private \SplQueue $compressedQueue;
180 private bool $forceAsyncCompression = true;
181 private bool $enableCompression = false; //disabled until handshake completed
182
183 private int $nextAckReceiptId = 0;
188 private array $ackPromisesByReceiptId = [];
189
190 private ?InventoryManager $invManager = null;
191
196 private ObjectSet $disposeHooks;
197
198 public function __construct(
199 private Server $server,
200 private NetworkSessionManager $manager,
201 private PacketPool $packetPool,
202 private PacketSender $sender,
203 private PacketBroadcaster $broadcaster,
204 private EntityEventBroadcaster $entityEventBroadcaster,
205 private Compressor $compressor,
206 private TypeConverter $typeConverter,
207 private string $ip,
208 private int $port
209 ){
210 $this->logger = new \PrefixedLogger($this->server->getLogger(), $this->getLogPrefix());
211
212 $this->compressedQueue = new \SplQueue();
213
214 $this->disposeHooks = new ObjectSet();
215
216 $this->connectTime = time();
217 $this->packetBatchLimiter = new PacketRateLimiter("Packet Batches", self::INCOMING_PACKET_BATCH_PER_TICK, self::INCOMING_PACKET_BATCH_BUFFER_TICKS);
218 $this->gamePacketLimiter = new PacketRateLimiter("Game Packets", self::INCOMING_GAME_PACKETS_PER_TICK, self::INCOMING_GAME_PACKETS_BUFFER_TICKS);
219
220 $this->setHandler(new SessionStartPacketHandler(
221 $this,
222 $this->onSessionStartSuccess(...)
223 ));
224
225 $this->manager->add($this);
226 $this->logger->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_network_session_open()));
227 }
228
229 private function getLogPrefix() : string{
230 return "NetworkSession: " . $this->getDisplayName();
231 }
232
233 public function getLogger() : \Logger{
234 return $this->logger;
235 }
236
237 private function onSessionStartSuccess() : void{
238 $this->logger->debug("Session start handshake completed, awaiting login packet");
239 $this->flushGamePacketQueue();
240 $this->enableCompression = true;
241 $this->setHandler(new LoginPacketHandler(
242 $this->server,
243 $this,
244 function(PlayerInfo $info) : void{
245 $this->info = $info;
246 $this->logger->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_network_session_playerName(TextFormat::AQUA . $info->getUsername() . TextFormat::RESET)));
247 $this->logger->setPrefix($this->getLogPrefix());
248 $this->manager->markLoginReceived($this);
249 },
250 $this->setAuthenticationStatus(...)
251 ));
252 }
253
254 protected function createPlayer() : void{
255 $this->server->createPlayer($this, $this->info, $this->authenticated, $this->cachedOfflinePlayerData)->onCompletion(
256 $this->onPlayerCreated(...),
257 function() : void{
258 //TODO: this should never actually occur... right?
259 $this->disconnectWithError(
260 reason: "Failed to create player",
261 disconnectScreenMessage: KnownTranslationFactory::pocketmine_disconnect_error_internal()
262 );
263 }
264 );
265 }
266
267 private function onPlayerCreated(Player $player) : void{
268 if(!$this->isConnected()){
269 //the remote player might have disconnected before spawn terrain generation was finished
270 return;
271 }
272 $this->player = $player;
273 if(!$this->server->addOnlinePlayer($player)){
274 return;
275 }
276
277 $this->invManager = new InventoryManager($this->player, $this);
278
279 $effectManager = $this->player->getEffects();
280 $effectManager->getEffectAddHooks()->add($effectAddHook = function(EffectInstance $effect, bool $replacesOldEffect) : void{
281 $this->entityEventBroadcaster->onEntityEffectAdded([$this], $this->player, $effect, $replacesOldEffect);
282 });
283 $effectManager->getEffectRemoveHooks()->add($effectRemoveHook = function(EffectInstance $effect) : void{
284 $this->entityEventBroadcaster->onEntityEffectRemoved([$this], $this->player, $effect);
285 });
286 $this->disposeHooks->add(static function() use ($effectManager, $effectAddHook, $effectRemoveHook) : void{
287 $effectManager->getEffectAddHooks()->remove($effectAddHook);
288 $effectManager->getEffectRemoveHooks()->remove($effectRemoveHook);
289 });
290
291 $permissionHooks = $this->player->getPermissionRecalculationCallbacks();
292 $permissionHooks->add($permHook = function() : void{
293 $this->logger->debug("Syncing available commands and abilities/permissions due to permission recalculation");
294 $this->syncAbilities($this->player);
295 $this->syncAvailableCommands();
296 });
297 $this->disposeHooks->add(static function() use ($permissionHooks, $permHook) : void{
298 $permissionHooks->remove($permHook);
299 });
300 $this->beginSpawnSequence();
301 }
302
303 public function getPlayer() : ?Player{
304 return $this->player;
305 }
306
307 public function getPlayerInfo() : ?PlayerInfo{
308 return $this->info;
309 }
310
311 public function isConnected() : bool{
312 return $this->connected && !$this->disconnectGuard;
313 }
314
315 public function getIp() : string{
316 return $this->ip;
317 }
318
319 public function getPort() : int{
320 return $this->port;
321 }
322
323 public function getDisplayName() : string{
324 return $this->info !== null ? $this->info->getUsername() : $this->ip . " " . $this->port;
325 }
326
330 public function getPing() : ?int{
331 return $this->ping;
332 }
333
337 public function updatePing(int $ping) : void{
338 $this->ping = $ping;
339 }
340
341 public function getHandler() : ?PacketHandler{
342 return $this->handler;
343 }
344
345 public function setHandler(?PacketHandler $handler) : void{
346 if($this->connected){ //TODO: this is fine since we can't handle anything from a disconnected session, but it might produce surprises in some cases
347 $this->handler = $handler;
348 if($this->handler !== null){
349 $this->handler->setUp();
350 }
351 }
352 }
353
357 public function handleEncoded(string $payload) : void{
358 if(!$this->connected){
359 return;
360 }
361
362 Timings::$playerNetworkReceive->startTiming();
363 try{
364 $this->packetBatchLimiter->decrement();
365
366 if($this->cipher !== null){
367 Timings::$playerNetworkReceiveDecrypt->startTiming();
368 try{
369 $payload = $this->cipher->decrypt($payload);
370 }catch(DecryptionException $e){
371 $this->logger->debug("Encrypted packet: " . base64_encode($payload));
372 throw PacketHandlingException::wrap($e, "Packet decryption error");
373 }finally{
374 Timings::$playerNetworkReceiveDecrypt->stopTiming();
375 }
376 }
377
378 if(strlen($payload) < 1){
379 throw new PacketHandlingException("No bytes in payload");
380 }
381
382 if($this->enableCompression){
383 $compressionType = ord($payload[0]);
384 $compressed = substr($payload, 1);
385 if($compressionType === CompressionAlgorithm::NONE){
386 $decompressed = $compressed;
387 }elseif($compressionType === $this->compressor->getNetworkId()){
388 Timings::$playerNetworkReceiveDecompress->startTiming();
389 try{
390 $decompressed = $this->compressor->decompress($compressed);
391 }catch(DecompressionException $e){
392 $this->logger->debug("Failed to decompress packet: " . base64_encode($compressed));
393 throw PacketHandlingException::wrap($e, "Compressed packet batch decode error");
394 }finally{
395 Timings::$playerNetworkReceiveDecompress->stopTiming();
396 }
397 }else{
398 throw new PacketHandlingException("Packet compressed with unexpected compression type $compressionType");
399 }
400 }else{
401 $decompressed = $payload;
402 }
403
404 try{
405 $stream = new ByteBufferReader($decompressed);
406 foreach(PacketBatch::decodeRaw($stream) as $buffer){
407 $this->gamePacketLimiter->decrement();
408 $packet = $this->packetPool->getPacket($buffer);
409 if($packet === null){
410 $this->logger->debug("Unknown packet: " . base64_encode($buffer));
411 throw new PacketHandlingException("Unknown packet received");
412 }
413 try{
414 $this->handleDataPacket($packet, $buffer);
415 }catch(PacketHandlingException $e){
416 $this->logger->debug($packet->getName() . ": " . base64_encode($buffer));
417 throw PacketHandlingException::wrap($e, "Error processing " . $packet->getName());
418 }
419 if(!$this->isConnected()){
420 //handling this packet may have caused a disconnection
421 $this->logger->debug("Aborting batch processing due to server-side disconnection");
422 break;
423 }
424 }
425 }catch(PacketDecodeException|DataDecodeException $e){
426 $this->logger->logException($e);
427 throw PacketHandlingException::wrap($e, "Packet batch decode error");
428 }
429 }finally{
430 Timings::$playerNetworkReceive->stopTiming();
431 }
432 }
433
437 public function handleDataPacket(Packet $packet, string $buffer) : void{
438 if(!($packet instanceof ServerboundPacket)){
439 throw new PacketHandlingException("Unexpected non-serverbound packet");
440 }
441
442 $timings = Timings::getReceiveDataPacketTimings($packet);
443 $timings->startTiming();
444
445 try{
446 if(DataPacketDecodeEvent::hasHandlers()){
447 $ev = new DataPacketDecodeEvent($this, $packet->pid(), $buffer);
448 $ev->call();
449 if($ev->isCancelled()){
450 return;
451 }
452 }
453
454 $decodeTimings = Timings::getDecodeDataPacketTimings($packet);
455 $decodeTimings->startTiming();
456 try{
457 $stream = new ByteBufferReader($buffer);
458 try{
459 $packet->decode($stream);
460 }catch(PacketDecodeException $e){
461 throw PacketHandlingException::wrap($e);
462 }
463 if($stream->getUnreadLength() > 0){
464 $remains = substr($stream->getData(), $stream->getOffset());
465 $this->logger->debug("Still " . strlen($remains) . " bytes unread in " . $packet->getName() . ": " . bin2hex($remains));
466 }
467 }finally{
468 $decodeTimings->stopTiming();
469 }
470
471 if(DataPacketReceiveEvent::hasHandlers()){
472 $ev = new DataPacketReceiveEvent($this, $packet);
473 $ev->call();
474 if($ev->isCancelled()){
475 return;
476 }
477 }
478 $handlerTimings = Timings::getHandleDataPacketTimings($packet);
479 $handlerTimings->startTiming();
480 try{
481 if($this->handler === null || !$packet->handle($this->handler)){
482 $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getData()));
483 }
484 }finally{
485 $handlerTimings->stopTiming();
486 }
487 }finally{
488 $timings->stopTiming();
489 }
490 }
491
492 public function handleAckReceipt(int $receiptId) : void{
493 if(!$this->connected){
494 return;
495 }
496 if(isset($this->ackPromisesByReceiptId[$receiptId])){
497 $promises = $this->ackPromisesByReceiptId[$receiptId];
498 unset($this->ackPromisesByReceiptId[$receiptId]);
499 foreach($promises as $promise){
500 $promise->resolve(true);
501 }
502 }
503 }
504
508 private function sendDataPacketInternal(ClientboundPacket $packet, bool $immediate, ?PromiseResolver $ackReceiptResolver) : bool{
509 if(!$this->connected){
510 return false;
511 }
512 //Basic safety restriction. TODO: improve this
513 if(!$this->loggedIn && !$packet->canBeSentBeforeLogin()){
514 throw new \InvalidArgumentException("Attempted to send " . get_class($packet) . " to " . $this->getDisplayName() . " too early");
515 }
516
517 $timings = Timings::getSendDataPacketTimings($packet);
518 $timings->startTiming();
519 try{
520 if(DataPacketSendEvent::hasHandlers()){
521 $ev = new DataPacketSendEvent([$this], [$packet]);
522 $ev->call();
523 if($ev->isCancelled()){
524 return false;
525 }
526 $packets = $ev->getPackets();
527 }else{
528 $packets = [$packet];
529 }
530
531 if($ackReceiptResolver !== null){
532 $this->sendBufferAckPromises[] = $ackReceiptResolver;
533 }
534 $writer = new ByteBufferWriter();
535 foreach($packets as $evPacket){
536 $writer->clear(); //memory reuse let's gooooo
537 $this->addToSendBuffer(self::encodePacketTimed($writer, $evPacket));
538 }
539 if($immediate){
540 $this->flushGamePacketQueue();
541 }
542
543 return true;
544 }finally{
545 $timings->stopTiming();
546 }
547 }
548
549 public function sendDataPacket(ClientboundPacket $packet, bool $immediate = false) : bool{
550 return $this->sendDataPacketInternal($packet, $immediate, null);
551 }
552
556 public function sendDataPacketWithReceipt(ClientboundPacket $packet, bool $immediate = false) : Promise{
558 $resolver = new PromiseResolver();
559
560 if(!$this->sendDataPacketInternal($packet, $immediate, $resolver)){
561 $resolver->reject();
562 }
563
564 return $resolver->getPromise();
565 }
566
570 public static function encodePacketTimed(ByteBufferWriter $serializer, ClientboundPacket $packet) : string{
571 $timings = Timings::getEncodeDataPacketTimings($packet);
572 $timings->startTiming();
573 try{
574 $packet->encode($serializer);
575 return $serializer->getData();
576 }finally{
577 $timings->stopTiming();
578 }
579 }
580
584 public function addToSendBuffer(string $buffer) : void{
585 $this->sendBuffer[] = $buffer;
586 }
587
588 private function flushGamePacketQueue() : void{
589 if(count($this->sendBuffer) > 0){
590 Timings::$playerNetworkSend->startTiming();
591 try{
592 $syncMode = null; //automatic
593 if($this->forceAsyncCompression){
594 $syncMode = false;
595 }
596
597 $stream = new ByteBufferWriter();
598 PacketBatch::encodeRaw($stream, $this->sendBuffer);
599
600 if($this->enableCompression){
601 $batch = $this->server->prepareBatch($stream->getData(), $this->compressor, $syncMode, Timings::$playerNetworkSendCompressSessionBuffer);
602 }else{
603 $batch = $stream->getData();
604 }
605 $this->sendBuffer = [];
606 $ackPromises = $this->sendBufferAckPromises;
607 $this->sendBufferAckPromises = [];
608 //these packets were already potentially buffered for up to 50ms - make sure the transport layer doesn't
609 //delay them any longer
610 $this->queueCompressedNoGamePacketFlush($batch, networkFlush: true, ackPromises: $ackPromises);
611 }finally{
612 Timings::$playerNetworkSend->stopTiming();
613 }
614 }
615 }
616
617 public function getBroadcaster() : PacketBroadcaster{ return $this->broadcaster; }
618
619 public function getEntityEventBroadcaster() : EntityEventBroadcaster{ return $this->entityEventBroadcaster; }
620
621 public function getCompressor() : Compressor{
622 return $this->compressor;
623 }
624
625 public function getTypeConverter() : TypeConverter{ return $this->typeConverter; }
626
627 public function queueCompressed(CompressBatchPromise|string $payload, bool $immediate = false) : void{
628 Timings::$playerNetworkSend->startTiming();
629 try{
630 //if the next packet causes a flush, avoid unnecessarily flushing twice
631 //however, if the next packet does *not* cause a flush, game packets should be flushed to avoid delays
632 $this->flushGamePacketQueue();
633 $this->queueCompressedNoGamePacketFlush($payload, $immediate);
634 }finally{
635 Timings::$playerNetworkSend->stopTiming();
636 }
637 }
638
644 private function queueCompressedNoGamePacketFlush(CompressBatchPromise|string $batch, bool $networkFlush = false, array $ackPromises = []) : void{
645 Timings::$playerNetworkSend->startTiming();
646 try{
647 $this->compressedQueue->enqueue([$batch, $ackPromises, $networkFlush]);
648 if(is_string($batch)){
649 $this->flushCompressedQueue();
650 }else{
651 $batch->onResolve(function() : void{
652 if($this->connected){
653 $this->flushCompressedQueue();
654 }
655 });
656 }
657 }finally{
658 Timings::$playerNetworkSend->stopTiming();
659 }
660 }
661
662 private function flushCompressedQueue() : void{
663 Timings::$playerNetworkSend->startTiming();
664 try{
665 while(!$this->compressedQueue->isEmpty()){
667 [$current, $ackPromises, $networkFlush] = $this->compressedQueue->bottom();
668 if(is_string($current)){
669 $this->compressedQueue->dequeue();
670 $this->sendEncoded($current, $networkFlush, $ackPromises);
671
672 }elseif($current->hasResult()){
673 $this->compressedQueue->dequeue();
674 $this->sendEncoded($current->getResult(), $networkFlush, $ackPromises);
675
676 }else{
677 //can't send any more queued until this one is ready
678 break;
679 }
680 }
681 }finally{
682 Timings::$playerNetworkSend->stopTiming();
683 }
684 }
685
690 private function sendEncoded(string $payload, bool $immediate, array $ackPromises) : void{
691 if($this->cipher !== null){
692 Timings::$playerNetworkSendEncrypt->startTiming();
693 $payload = $this->cipher->encrypt($payload);
694 Timings::$playerNetworkSendEncrypt->stopTiming();
695 }
696
697 if(count($ackPromises) > 0){
698 $ackReceiptId = $this->nextAckReceiptId++;
699 $this->ackPromisesByReceiptId[$ackReceiptId] = $ackPromises;
700 }else{
701 $ackReceiptId = null;
702 }
703 $this->sender->send($payload, $immediate, $ackReceiptId);
704 }
705
709 private function tryDisconnect(\Closure $func, Translatable|string $reason) : void{
710 if($this->connected && !$this->disconnectGuard){
711 $this->disconnectGuard = true;
712 $func();
713 $this->disconnectGuard = false;
714 $this->flushGamePacketQueue();
715 $this->sender->close("");
716 foreach($this->disposeHooks as $callback){
717 $callback();
718 }
719 $this->disposeHooks->clear();
720 $this->setHandler(null);
721 $this->connected = false;
722
723 $ackPromisesByReceiptId = $this->ackPromisesByReceiptId;
724 $this->ackPromisesByReceiptId = [];
725 foreach($ackPromisesByReceiptId as $resolvers){
726 foreach($resolvers as $resolver){
727 $resolver->reject();
728 }
729 }
730 $sendBufferAckPromises = $this->sendBufferAckPromises;
731 $this->sendBufferAckPromises = [];
732 foreach($sendBufferAckPromises as $resolver){
733 $resolver->reject();
734 }
735
736 $this->logger->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_network_session_close($reason)));
737 }
738 }
739
744 private function dispose() : void{
745 $this->invManager = null;
746 }
747
748 private function sendDisconnectPacket(Translatable|string $message) : void{
749 if($message instanceof Translatable){
750 $translated = $this->server->getLanguage()->translate($message);
751 }else{
752 $translated = $message;
753 }
754 $this->sendDataPacket(DisconnectPacket::create(0, $translated, ""));
755 }
756
763 public function disconnect(Translatable|string $reason, Translatable|string|null $disconnectScreenMessage = null, bool $notify = true) : void{
764 $this->tryDisconnect(function() use ($reason, $disconnectScreenMessage, $notify) : void{
765 if($notify){
766 $this->sendDisconnectPacket($disconnectScreenMessage ?? $reason);
767 }
768 if($this->player !== null){
769 $this->player->onPostDisconnect($reason, null);
770 }
771 }, $reason);
772 }
773
774 public function disconnectWithError(Translatable|string $reason, Translatable|string|null $disconnectScreenMessage = null) : void{
775 $errorId = implode("-", str_split(bin2hex(random_bytes(6)), 4));
776
777 $this->disconnect(
778 reason: KnownTranslationFactory::pocketmine_disconnect_error($reason, $errorId)->prefix(TextFormat::RED),
779 disconnectScreenMessage: KnownTranslationFactory::pocketmine_disconnect_error($disconnectScreenMessage ?? $reason, $errorId),
780 );
781 }
782
783 public function disconnectIncompatibleProtocol(int $protocolVersion) : void{
784 $this->tryDisconnect(
785 function() use ($protocolVersion) : void{
786 $this->sendDataPacket(PlayStatusPacket::create($protocolVersion < ProtocolInfo::CURRENT_PROTOCOL ? PlayStatusPacket::LOGIN_FAILED_CLIENT : PlayStatusPacket::LOGIN_FAILED_SERVER), true);
787 },
788 KnownTranslationFactory::pocketmine_disconnect_incompatibleProtocol((string) $protocolVersion)
789 );
790 }
791
795 public function transfer(string $ip, int $port, Translatable|string|null $reason = null) : void{
796 $reason ??= KnownTranslationFactory::pocketmine_disconnect_transfer();
797 $this->tryDisconnect(function() use ($ip, $port, $reason) : void{
798 $this->sendDataPacket(TransferPacket::create($ip, $port, false), true);
799 if($this->player !== null){
800 $this->player->onPostDisconnect($reason, null);
801 }
802 }, $reason);
803 }
804
808 public function onPlayerDestroyed(Translatable|string $reason, Translatable|string $disconnectScreenMessage) : void{
809 $this->tryDisconnect(function() use ($disconnectScreenMessage) : void{
810 $this->sendDisconnectPacket($disconnectScreenMessage);
811 }, $reason);
812 }
813
818 public function onClientDisconnect(Translatable|string $reason) : void{
819 $this->tryDisconnect(function() use ($reason) : void{
820 if($this->player !== null){
821 $this->player->onPostDisconnect($reason, null);
822 }
823 }, $reason);
824 }
825
826 private function setAuthenticationStatus(bool $authenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPubKey) : void{
827 if(!$this->connected){
828 return;
829 }
830 if($error === null){
831 if($authenticated && !($this->info instanceof XboxLivePlayerInfo)){
832 $error = "Expected XUID but none found";
833 }elseif($clientPubKey === null){
834 $error = "Missing client public key"; //failsafe
835 }
836 }
837
838 if($error !== null){
839 $this->disconnectWithError(
840 reason: KnownTranslationFactory::pocketmine_disconnect_invalidSession($error),
841 disconnectScreenMessage: KnownTranslationFactory::pocketmine_disconnect_error_authentication()
842 );
843
844 return;
845 }
846
847 $this->authenticated = $authenticated;
848
849 if(!$this->authenticated){
850 if($authRequired){
851 $this->disconnect("Not authenticated", KnownTranslationFactory::disconnectionScreen_notAuthenticated());
852 return;
853 }
854 if($this->info instanceof XboxLivePlayerInfo){
855 $this->logger->warning("Discarding unexpected XUID for non-authenticated player");
856 $this->info = $this->info->withoutXboxData();
857 }
858 }
859 $this->logger->debug("Xbox Live authenticated: " . ($this->authenticated ? "YES" : "NO"));
860
861 $checkXUID = $this->server->getConfigGroup()->getPropertyBool(YmlServerProperties::PLAYER_VERIFY_XUID, true);
862 $myXUID = $this->info instanceof XboxLivePlayerInfo ? $this->info->getXuid() : "";
863 $kickForXUIDMismatch = function(string $xuid) use ($checkXUID, $myXUID) : bool{
864 if($checkXUID && $myXUID !== $xuid){
865 $this->logger->debug("XUID mismatch: expected '$xuid', but got '$myXUID'");
866 //TODO: Longer term, we should be identifying playerdata using something more reliable, like XUID or UUID.
867 //However, that would be a very disruptive change, so this will serve as a stopgap for now.
868 //Side note: this will also prevent offline players hijacking XBL playerdata on online servers, since their
869 //XUID will always be empty.
870 $this->disconnect("XUID does not match (possible impersonation attempt)");
871 return true;
872 }
873 return false;
874 };
875
876 foreach($this->manager->getSessions() as $existingSession){
877 if($existingSession === $this){
878 continue;
879 }
880 $info = $existingSession->getPlayerInfo();
881 if($info !== null && (strcasecmp($info->getUsername(), $this->info->getUsername()) === 0 || $info->getUuid()->equals($this->info->getUuid()))){
882 if($kickForXUIDMismatch($info instanceof XboxLivePlayerInfo ? $info->getXuid() : "")){
883 return;
884 }
885 $ev = new PlayerDuplicateLoginEvent($this, $existingSession, KnownTranslationFactory::disconnectionScreen_loggedinOtherLocation(), null);
886 $ev->call();
887 if($ev->isCancelled()){
888 $this->disconnect($ev->getDisconnectReason(), $ev->getDisconnectScreenMessage());
889 return;
890 }
891
892 $existingSession->disconnect($ev->getDisconnectReason(), $ev->getDisconnectScreenMessage());
893 }
894 }
895
896 //TODO: make player data loading async
897 //TODO: we shouldn't be loading player data here at all, but right now we don't have any choice :(
898 $this->cachedOfflinePlayerData = $this->server->getOfflinePlayerData($this->info->getUsername());
899 if($checkXUID){
900 $recordedXUID = $this->cachedOfflinePlayerData !== null ? $this->cachedOfflinePlayerData->getTag(Player::TAG_LAST_KNOWN_XUID) : null;
901 if(!($recordedXUID instanceof StringTag)){
902 $this->logger->debug("No previous XUID recorded, no choice but to trust this player");
903 }elseif(!$kickForXUIDMismatch($recordedXUID->getValue())){
904 $this->logger->debug("XUID match");
905 }
906 }
907
908 if(EncryptionContext::$ENABLED){
909 $this->server->getAsyncPool()->submitTask(new PrepareEncryptionTask($clientPubKey, function(string $encryptionKey, string $handshakeJwt) : void{
910 if(!$this->connected){
911 return;
912 }
913 $this->sendDataPacket(ServerToClientHandshakePacket::create($handshakeJwt), true); //make sure this gets sent before encryption is enabled
914
915 $this->cipher = EncryptionContext::fakeGCM($encryptionKey);
916
917 $this->setHandler(new HandshakePacketHandler($this->onServerLoginSuccess(...)));
918 $this->logger->debug("Enabled encryption");
919 }));
920 }else{
921 $this->onServerLoginSuccess();
922 }
923 }
924
925 private function onServerLoginSuccess() : void{
926 $this->loggedIn = true;
927
928 $this->sendDataPacket(PlayStatusPacket::create(PlayStatusPacket::LOGIN_SUCCESS));
929
930 $this->logger->debug("Initiating resource packs phase");
931
932 $packManager = $this->server->getResourcePackManager();
933 $resourcePacks = $packManager->getResourceStack();
934 $keys = [];
935 foreach($resourcePacks as $resourcePack){
936 $key = $packManager->getPackEncryptionKey($resourcePack->getPackId());
937 if($key !== null){
938 $keys[$resourcePack->getPackId()] = $key;
939 }
940 }
941 $event = new PlayerResourcePackOfferEvent($this->info, $resourcePacks, $keys, $packManager->resourcePacksRequired());
942 $event->call();
943 $this->setHandler(new ResourcePacksPacketHandler($this, $event->getResourcePacks(), $event->getEncryptionKeys(), $event->mustAccept(), function() : void{
944 $this->createPlayer();
945 }));
946 }
947
948 private function beginSpawnSequence() : void{
949 $this->setHandler(new PreSpawnPacketHandler($this->server, $this->player, $this, $this->invManager));
950 $this->player->setNoClientPredictions(); //TODO: HACK: fix client-side falling pre-spawn
951
952 $this->logger->debug("Waiting for chunk radius request");
953 }
954
955 public function notifyTerrainReady() : void{
956 $this->logger->debug("Sending spawn notification, waiting for spawn response");
957 $this->sendDataPacket(PlayStatusPacket::create(PlayStatusPacket::PLAYER_SPAWN));
958 $this->setHandler(new SpawnResponsePacketHandler($this->onClientSpawnResponse(...)));
959 }
960
961 private function onClientSpawnResponse() : void{
962 $this->logger->debug("Received spawn response, entering in-game phase");
963 $this->player->setNoClientPredictions(false); //TODO: HACK: we set this during the spawn sequence to prevent the client sending junk movements
964 $this->player->doFirstSpawn();
965 $this->forceAsyncCompression = false;
966 $this->setHandler(new InGamePacketHandler($this->player, $this, $this->invManager));
967 }
968
969 public function onServerDeath(Translatable|string $deathMessage) : void{
970 if($this->handler instanceof InGamePacketHandler){ //TODO: this is a bad fix for pre-spawn death, this shouldn't be reachable at all at this stage :(
971 $this->setHandler(new DeathPacketHandler($this->player, $this, $this->invManager ?? throw new AssumptionFailedError(), $deathMessage));
972 }
973 }
974
975 public function onServerRespawn() : void{
976 $this->entityEventBroadcaster->syncAttributes([$this], $this->player, $this->player->getAttributeMap()->getAll());
977 $this->player->sendData(null);
978
979 $this->syncAbilities($this->player);
980 $this->invManager->syncAll();
981 $this->setHandler(new InGamePacketHandler($this->player, $this, $this->invManager));
982 }
983
984 public function syncMovement(Vector3 $pos, ?float $yaw = null, ?float $pitch = null, int $mode = MovePlayerPacket::MODE_NORMAL) : void{
985 if($this->player !== null){
986 $location = $this->player->getLocation();
987 $yaw = $yaw ?? $location->getYaw();
988 $pitch = $pitch ?? $location->getPitch();
989
990 $this->sendDataPacket(MovePlayerPacket::simple(
991 $this->player->getId(),
992 $this->player->getOffsetPosition($pos),
993 $pitch,
994 $yaw,
995 $yaw, //TODO: head yaw
996 $mode,
997 $this->player->onGround,
998 0, //TODO: riding entity ID
999 0 //TODO: tick
1000 ));
1001
1002 if($this->handler instanceof InGamePacketHandler){
1003 $this->handler->forceMoveSync = true;
1004 }
1005 }
1006 }
1007
1008 public function syncViewAreaRadius(int $distance) : void{
1009 $this->sendDataPacket(ChunkRadiusUpdatedPacket::create($distance));
1010 }
1011
1012 public function syncViewAreaCenterPoint(Vector3 $newPos, int $viewDistance) : void{
1013 $this->sendDataPacket(NetworkChunkPublisherUpdatePacket::create(BlockPosition::fromVector3($newPos), $viewDistance * 16, [])); //blocks, not chunks >.>
1014 }
1015
1016 public function syncPlayerSpawnPoint(Position $newSpawn) : void{
1017 $newSpawnBlockPosition = BlockPosition::fromVector3($newSpawn);
1018 //TODO: respawn causing block position (bed, respawn anchor)
1019 $this->sendDataPacket(SetSpawnPositionPacket::playerSpawn($newSpawnBlockPosition, DimensionIds::OVERWORLD, $newSpawnBlockPosition));
1020 }
1021
1022 public function syncWorldSpawnPoint(Position $newSpawn) : void{
1023 $this->sendDataPacket(SetSpawnPositionPacket::worldSpawn(BlockPosition::fromVector3($newSpawn), DimensionIds::OVERWORLD));
1024 }
1025
1026 public function syncGameMode(GameMode $mode, bool $isRollback = false) : void{
1027 $this->sendDataPacket(SetPlayerGameTypePacket::create($this->typeConverter->coreGameModeToProtocol($mode)));
1028 if($this->player !== null){
1029 $this->syncAbilities($this->player);
1030 $this->syncAdventureSettings(); //TODO: we might be able to do this with the abilities packet alone
1031 }
1032 if(!$isRollback && $this->invManager !== null){
1033 $this->invManager->syncCreative();
1034 }
1035 }
1036
1037 public function syncAbilities(Player $for) : void{
1038 $isOp = $for->hasPermission(DefaultPermissions::ROOT_OPERATOR);
1039
1040 //ALL of these need to be set for the base layer, otherwise the client will cry
1041 $boolAbilities = [
1042 AbilitiesLayer::ABILITY_ALLOW_FLIGHT => $for->getAllowFlight(),
1043 AbilitiesLayer::ABILITY_FLYING => $for->isFlying(),
1044 AbilitiesLayer::ABILITY_NO_CLIP => !$for->hasBlockCollision(),
1045 AbilitiesLayer::ABILITY_OPERATOR => $isOp,
1046 AbilitiesLayer::ABILITY_TELEPORT => $for->hasPermission(DefaultPermissionNames::COMMAND_TELEPORT_SELF),
1047 AbilitiesLayer::ABILITY_INVULNERABLE => $for->isCreative(),
1048 AbilitiesLayer::ABILITY_MUTED => false,
1049 AbilitiesLayer::ABILITY_WORLD_BUILDER => false,
1050 AbilitiesLayer::ABILITY_INFINITE_RESOURCES => !$for->hasFiniteResources(),
1051 AbilitiesLayer::ABILITY_LIGHTNING => false,
1052 AbilitiesLayer::ABILITY_BUILD => !$for->isSpectator(),
1053 AbilitiesLayer::ABILITY_MINE => !$for->isSpectator(),
1054 AbilitiesLayer::ABILITY_DOORS_AND_SWITCHES => !$for->isSpectator(),
1055 AbilitiesLayer::ABILITY_OPEN_CONTAINERS => !$for->isSpectator(),
1056 AbilitiesLayer::ABILITY_ATTACK_PLAYERS => !$for->isSpectator(),
1057 AbilitiesLayer::ABILITY_ATTACK_MOBS => !$for->isSpectator(),
1058 AbilitiesLayer::ABILITY_PRIVILEGED_BUILDER => false,
1059 ];
1060
1061 $layers = [
1062 new AbilitiesLayer(AbilitiesLayer::LAYER_BASE, $boolAbilities, $for->getFlightSpeedMultiplier(), 1, 0.1),
1063 ];
1064 if(!$for->hasBlockCollision()){
1065 //TODO: HACK! In 1.19.80, the client starts falling in our faux spectator mode when it clips into a
1066 //block. We can't seem to prevent this short of forcing the player to always fly when block collision is
1067 //disabled. Also, for some reason the client always reads flight state from this layer if present, even
1068 //though the player isn't in spectator mode.
1069
1070 $layers[] = new AbilitiesLayer(AbilitiesLayer::LAYER_SPECTATOR, [
1071 AbilitiesLayer::ABILITY_FLYING => true,
1072 ], null, null, null);
1073 }
1074
1075 $this->sendDataPacket(UpdateAbilitiesPacket::create(new AbilitiesData(
1076 $isOp ? CommandPermissions::OPERATOR : CommandPermissions::NORMAL,
1077 $isOp ? PlayerPermissions::OPERATOR : PlayerPermissions::MEMBER,
1078 $for->getId(),
1079 $layers
1080 )));
1081 }
1082
1083 public function syncAdventureSettings() : void{
1084 if($this->player === null){
1085 throw new \LogicException("Cannot sync adventure settings for a player that is not yet created");
1086 }
1087 //everything except auto jump is handled via UpdateAbilitiesPacket
1088 $this->sendDataPacket(UpdateAdventureSettingsPacket::create(
1089 noAttackingMobs: false,
1090 noAttackingPlayers: false,
1091 worldImmutable: false,
1092 showNameTags: true,
1093 autoJump: $this->player->hasAutoJump()
1094 ));
1095 }
1096
1097 public function syncAvailableCommands() : void{
1098 $commandData = [];
1099 $globalAliasMap = $this->server->getCommandMap()->getAliasMap();
1100 $userAliasMap = $this->player->getCommandAliasMap();
1101 foreach($this->server->getCommandMap()->getUniqueCommands() as $command){
1102 if(!$command->testPermissionSilent($this->player)){
1103 continue;
1104 }
1105
1106 $userAliases = $userAliasMap->getMergedAliases($command->getId(), $globalAliasMap);
1107 //the client doesn't like it when we override /help
1108 $aliases = array_values(array_filter($userAliases, fn(string $alias) => $alias !== "help" && $alias !== "?"));
1109 if(count($aliases) === 0){
1110 continue;
1111 }
1112 $firstNetworkAlias = $aliases[0];
1113 //use filtered aliases for command name discovery - this allows /help to still be shown as /pocketmine:help
1114 //on the client without conflicting with the client's built-in /help command
1115 $lname = strtolower($firstNetworkAlias);
1116 $aliasObj = new CommandEnum(ucfirst($firstNetworkAlias) . "Aliases", $aliases);
1117
1118 $description = $command->getDescription();
1119 $data = new CommandData(
1120 $lname, //TODO: commands containing uppercase letters in the name crash 1.9.0 client
1121 $description instanceof Translatable ? $this->player->getLanguage()->translate($description) : $description,
1122 0,
1123 0,
1124 $aliasObj,
1125 [
1126 new CommandOverload(chaining: false, parameters: [CommandParameter::standard("args", AvailableCommandsPacket::ARG_TYPE_RAWTEXT, 0, true)])
1127 ],
1128 chainedSubCommandData: []
1129 );
1130
1131 $commandData[] = $data;
1132 }
1133
1134 $this->sendDataPacket(AvailableCommandsPacket::create($commandData, [], [], []));
1135 }
1136
1141 public function prepareClientTranslatableMessage(Translatable $message) : array{
1142 //we can't send nested translations to the client, so make sure they are always pre-translated by the server
1143 $language = $this->player->getLanguage();
1144 $parameters = array_map(fn(string|Translatable $p) => $p instanceof Translatable ? $language->translate($p) : $p, $message->getParameters());
1145 return [$language->translateString($message->getText(), $parameters, "pocketmine."), $parameters];
1146 }
1147
1148 public function onChatMessage(Translatable|string $message) : void{
1149 if($message instanceof Translatable){
1150 if(!$this->server->isLanguageForced()){
1151 $this->sendDataPacket(TextPacket::translation(...$this->prepareClientTranslatableMessage($message)));
1152 }else{
1153 $this->sendDataPacket(TextPacket::raw($this->player->getLanguage()->translate($message)));
1154 }
1155 }else{
1156 $this->sendDataPacket(TextPacket::raw($message));
1157 }
1158 }
1159
1160 public function onJukeboxPopup(Translatable|string $message) : void{
1161 $parameters = [];
1162 if($message instanceof Translatable){
1163 if(!$this->server->isLanguageForced()){
1164 [$message, $parameters] = $this->prepareClientTranslatableMessage($message);
1165 }else{
1166 $message = $this->player->getLanguage()->translate($message);
1167 }
1168 }
1169 $this->sendDataPacket(TextPacket::jukeboxPopup($message, $parameters));
1170 }
1171
1172 public function onPopup(string $message) : void{
1173 $this->sendDataPacket(TextPacket::popup($message));
1174 }
1175
1176 public function onTip(string $message) : void{
1177 $this->sendDataPacket(TextPacket::tip($message));
1178 }
1179
1180 public function onFormSent(int $id, Form $form) : bool{
1181 return $this->sendDataPacket(ModalFormRequestPacket::create($id, json_encode($form, JSON_THROW_ON_ERROR)));
1182 }
1183
1184 public function onCloseAllForms() : void{
1185 $this->sendDataPacket(ClientboundCloseFormPacket::create());
1186 }
1187
1191 private function sendChunkPacket(string $chunkPacket, \Closure $onCompletion, World $world) : void{
1192 $world->timings->syncChunkSend->startTiming();
1193 try{
1194 $this->queueCompressed($chunkPacket);
1195 $onCompletion();
1196 }finally{
1197 $world->timings->syncChunkSend->stopTiming();
1198 }
1199 }
1200
1206 public function startUsingChunk(int $chunkX, int $chunkZ, \Closure $onCompletion) : void{
1207 $world = $this->player->getLocation()->getWorld();
1208 $promiseOrPacket = ChunkCache::getInstance($world, $this->compressor)->request($chunkX, $chunkZ);
1209 if(is_string($promiseOrPacket)){
1210 $this->sendChunkPacket($promiseOrPacket, $onCompletion, $world);
1211 return;
1212 }
1213 $promiseOrPacket->onResolve(
1214 //this callback may be called synchronously or asynchronously, depending on whether the promise is resolved yet
1215 function(CompressBatchPromise $promise) use ($world, $onCompletion, $chunkX, $chunkZ) : void{
1216 if(!$this->isConnected()){
1217 return;
1218 }
1219 $currentWorld = $this->player->getLocation()->getWorld();
1220 if($world !== $currentWorld || ($status = $this->player->getUsedChunkStatus($chunkX, $chunkZ)) === null){
1221 $this->logger->debug("Tried to send no-longer-active chunk $chunkX $chunkZ in world " . $world->getFolderName());
1222 return;
1223 }
1224 if($status !== UsedChunkStatus::REQUESTED_SENDING){
1225 //TODO: make this an error
1226 //this could be triggered due to the shitty way that chunk resends are handled
1227 //right now - not because of the spammy re-requesting, but because the chunk status reverts
1228 //to NEEDED if they want to be resent.
1229 return;
1230 }
1231 $this->sendChunkPacket($promise->getResult(), $onCompletion, $world);
1232 }
1233 );
1234 }
1235
1236 public function stopUsingChunk(int $chunkX, int $chunkZ) : void{
1237
1238 }
1239
1240 public function onEnterWorld() : void{
1241 if($this->player !== null){
1242 $world = $this->player->getWorld();
1243 $this->syncWorldTime($world->getTime());
1244 $this->syncWorldDifficulty($world->getDifficulty());
1245 $this->syncWorldSpawnPoint($world->getSpawnLocation());
1246 //TODO: weather needs to be synced here (when implemented)
1247 }
1248 }
1249
1250 public function syncWorldTime(int $worldTime) : void{
1251 $this->sendDataPacket(SetTimePacket::create($worldTime));
1252 }
1253
1254 public function syncWorldDifficulty(int $worldDifficulty) : void{
1255 $this->sendDataPacket(SetDifficultyPacket::create($worldDifficulty));
1256 }
1257
1258 public function getInvManager() : ?InventoryManager{
1259 return $this->invManager;
1260 }
1261
1265 public function syncPlayerList(array $players) : void{
1266 $this->sendDataPacket(PlayerListPacket::add(array_map(function(Player $player) : PlayerListEntry{
1267 return PlayerListEntry::createAdditionEntry($player->getUniqueId(), $player->getId(), $player->getDisplayName(), $this->typeConverter->getSkinAdapter()->toSkinData($player->getSkin()), $player->getXuid());
1268 }, $players)));
1269 }
1270
1271 public function onPlayerAdded(Player $p) : void{
1272 $this->sendDataPacket(PlayerListPacket::add([PlayerListEntry::createAdditionEntry($p->getUniqueId(), $p->getId(), $p->getDisplayName(), $this->typeConverter->getSkinAdapter()->toSkinData($p->getSkin()), $p->getXuid())]));
1273 }
1274
1275 public function onPlayerRemoved(Player $p) : void{
1276 if($p !== $this->player){
1277 $this->sendDataPacket(PlayerListPacket::remove([PlayerListEntry::createRemovalEntry($p->getUniqueId())]));
1278 }
1279 }
1280
1281 public function onTitle(string $title) : void{
1282 $this->sendDataPacket(SetTitlePacket::title($title));
1283 }
1284
1285 public function onSubTitle(string $subtitle) : void{
1286 $this->sendDataPacket(SetTitlePacket::subtitle($subtitle));
1287 }
1288
1289 public function onActionBar(string $actionBar) : void{
1290 $this->sendDataPacket(SetTitlePacket::actionBarMessage($actionBar));
1291 }
1292
1293 public function onClearTitle() : void{
1294 $this->sendDataPacket(SetTitlePacket::clearTitle());
1295 }
1296
1297 public function onResetTitleOptions() : void{
1298 $this->sendDataPacket(SetTitlePacket::resetTitleOptions());
1299 }
1300
1301 public function onTitleDuration(int $fadeIn, int $stay, int $fadeOut) : void{
1302 $this->sendDataPacket(SetTitlePacket::setAnimationTimes($fadeIn, $stay, $fadeOut));
1303 }
1304
1305 public function onToastNotification(string $title, string $body) : void{
1306 $this->sendDataPacket(ToastRequestPacket::create($title, $body));
1307 }
1308
1309 public function onOpenSignEditor(Vector3 $signPosition, bool $frontSide) : void{
1310 $this->sendDataPacket(OpenSignPacket::create(BlockPosition::fromVector3($signPosition), $frontSide));
1311 }
1312
1313 public function onItemCooldownChanged(Item $item, int $ticks) : void{
1314 $this->sendDataPacket(PlayerStartItemCooldownPacket::create(
1315 GlobalItemDataHandlers::getSerializer()->serializeType($item)->getName(),
1316 $ticks
1317 ));
1318 }
1319
1320 public function tick() : void{
1321 if(!$this->isConnected()){
1322 $this->dispose();
1323 return;
1324 }
1325
1326 if($this->info === null){
1327 if(time() >= $this->connectTime + 10){
1328 $this->disconnectWithError(KnownTranslationFactory::pocketmine_disconnect_error_loginTimeout());
1329 }
1330
1331 return;
1332 }
1333
1334 if($this->player !== null){
1335 $this->player->doChunkRequests();
1336
1337 $dirtyAttributes = $this->player->getAttributeMap()->needSend();
1338 $this->entityEventBroadcaster->syncAttributes([$this], $this->player, $dirtyAttributes);
1339 foreach($dirtyAttributes as $attribute){
1340 //TODO: we might need to send these to other players in the future
1341 //if that happens, this will need to become more complex than a flag on the attribute itself
1342 $attribute->markSynchronized();
1343 }
1344 }
1345 Timings::$playerNetworkSendInventorySync->startTiming();
1346 try{
1347 $this->invManager?->flushPendingUpdates();
1348 }finally{
1349 Timings::$playerNetworkSendInventorySync->stopTiming();
1350 }
1351
1352 $this->flushGamePacketQueue();
1353 }
1354}
handleDataPacket(Packet $packet, string $buffer)
prepareClientTranslatableMessage(Translatable $message)
onPlayerDestroyed(Translatable|string $reason, Translatable|string $disconnectScreenMessage)
sendDataPacketWithReceipt(ClientboundPacket $packet, bool $immediate=false)
onClientDisconnect(Translatable|string $reason)
transfer(string $ip, int $port, Translatable|string|null $reason=null)
disconnect(Translatable|string $reason, Translatable|string|null $disconnectScreenMessage=null, bool $notify=true)
startUsingChunk(int $chunkX, int $chunkZ, \Closure $onCompletion)