AccueilÀ proposMe contacter

Anvil MC Bedrock

Motivation

Bien qu'il existe un serveur officiel Minecraft Bedrock Edition il est lent, gourmant en mémoire et pas extensible. C'est pour cela que j'ai choisi Rust, un langage performant, sûr et massivement parallélisable pour programmer cette implémentation qui se veut performante et adaptée aux processeurs modernes. C'était un gros bémol de MCBE qui est maintenant comblé.

Comment obtenir les spécifications du protocole

J'ai commencé par annalyser les requêtes envoyés par le client et le serveur officiel. J'ai tout de suite remarqué que le protocol était basé sur un fork de raknet. J'ai donc cherché des implémentations de raknet en rust mais j'en n'en n'ai trouvé aucune. J'ai donc du l'implémenter moi même.

Le principe de la runtime tokio

Tokio est la runtime asynchrone la plus populaire en Rust. Si vous ne savez pas ce qu'est une runtime asynchrone laissez moi vous rappeler le principe.
Une runtime asynchrone est un gestionnaire de regroupement de thread appelé thread-pool capable de gérer l'execution de tâches en parallèle. Si vous avez déja fait du Go vous êtes probablement familiarisés avec le principe de Green Thread. Et bien une runtime en rust utilise le même principe, elles permettent de créer des threads sans surcoûts pour de petites tâches.
C'est une des raisons qui font de rust un excellent langage pour le réseau et la gestion de systèmes critiques. Par exemple, CloudFlare utilise Rust avec Actix pour tous ses serveurs. Actix est un framework HTTP basé sur Tokio qui a l'avantage d'être sécurisé et le framework le plus rapide d'après TechEmpower https://www.techempower.com/benchmarks/#section=data-r18&hw=cl&test=composite.
Cette runtime permet avec une simplicité déconcertante de créer des threads "jetables".

// Cette macro procédurable permet a Tokio de gérer et synchroniser le main dans la runtime
#[tokio::main]
async fn main() {
    // On créé un Green Thread
    tokio::spawn(async {
        // Nous sommes dans le green thread
        // Si vous êtes familiers avec JS ou Go les async, yield et await sont les mêmes ici.
    });
}

Elle supporte aussi une implémentation de nombreux protocoles réseau comme UDP ou TCP optimisés pour la pipeline tokio.

Le serveur UDP Pricipal + les canaux

Pour créer un socket UDP avec Tokio:

// On ouvre un socket UDP en 0.0.0.0:19312
let udp = UdpSocket::bind("0.0.0.0:19312".parse().expect("L'adresse IP est invalide")).await.expect("Impossible d'ouvrir un serveur a l'adresse");

Pour gérer la reception d'un paquet

loop {
    let mut buffer = vec![0; 1024*128];
    let (size, peer) = udp.recv_from(&mut buffer);
    tokio::spawn(async {
        // On créé un itérateur sur notre buffer qui prendra uniquement les `size` permier bytes.
        let mut iter = buffer.iter().take(size);

        // On gère de décodage du paquet ici
    });
}

Une autre approche au problème et de créer des micro-services communiquants entre eux avec des canaux. La STD de rust fournit une solution appellée mspc néanmoins crossbeam-channel est fréquemment utilisé car plus performant et pratique.
On pourait aussi utiliser un système hybride: Par exemple gérer toutes les nouvelles connexions dans un green thread et avoir un service qui s'occupe d'actualiser le monde. La communication entre les deux pourrait se faire a l'aide d'un canal.

struct World {
    receiver: Receiver<Modification>,
    blocks: HashMap<[u8; 3], u8>
}
impl World {
    fn tick_world(&mut self) {
        while let Ok(e) = self.receiver.try_recv() {
            let Modification {coord, r#type} = e;
            println!("Block en {:?} modifié en {:?}",coord,r#type);
            self.blocks.insert(coord,r#type);
        }
    }
}
enum Modification {
    PlaceBlock {
        coord: [u8; 3]
        // Le r# est la pour dire au compileur de traiter le type qui suit comme un 
        // literal et pas un mot-clé
        r#type: u8
    }
}
#[tokio::main]
async fn main() {
    let (receiver,sender) = crossbeam_channel::unbounded();
    tokio::spawn(async {
        let mut world = World {
            receiver,
            blocks: Default::default()
        };
        loop {
            world.tick_world();
            yield;
        }
    });
    loop {
        // On crée un buffer de 4096 bytes sur le stack
        let mut buffer = vec![0; 4096];
        // On attend une requette réseau
        let (size, peer) = udp.recv_from(&mut buffer);
        // On prend seulement les `size` premiers bytes du buffer
        let mut iter = buffer.iter().take(size);
        // On vérifie que l'iter a un premier byte de valeur 0x3B l'id du paquet de modification de block
        if let Some(0x3B) = iter.next() {
            // Si la fonction renvoie None on affiche "Paquet de modification invalide!" sinon on passe au Serveur la modification au travers du canal
            if let Some(e) = handle_block_update_packet(iter) {
                sender.send(e);
            } else {
                println!("Paquet de modification invalide!");
            }
        }
    }
}

fn handle_block_update_packet(mut iter: impl Iter<Item = u8>) -> Option<Modification> {
    // Grâce au ? si l'iter n'a pas d'élément suivant la fonction retournera None
    Some(Modification {
        coord: [iter.next()?,iter.next()?,iter.next()?],
        r#type: iter.next()?
    })
}

Lien de l'implémentation utilisé sur Anvil MC Bedrock
L'implémentation que j'utilise est légèrement différente

Comme vous avez pu le voir c'est relativement facile de créer des architectures, sûres, performantes et robustes en Rust. Nous sommes de plus assurés qu'il ne peut pas y avoir de data-race ou de concurrent access exception comme Rust check tout a la compilation. Si une faille se trouve quelque part, le programme ne se compilera pas!


Comparaison de performances entre le serveur officiel et Anvil MC Bedrock.

Ici un graphique du temps par requête en ms.Benchmark réalisé sur un Ryzen 3600XT (6 cores, 12 threads, 4.5GHz)