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