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