Return to home

DOOM, Captcha et WASM à la Nuit de l'info


7 décembre 2024

Prélude

J'ai eu la chance de participer à la session 2024 de La Nuit de l'Info au sein de l'équipe des Ratiscrum. Pour ceux qui ne connaissent pas la nuit de l'info, c'est un évènement regroupant des étudiants de différents horizons face à un défi national et de multiples défis proposés par des entreprises partenaires offrant des lots à gagner. Cette année, un défi m'a particulièrement attiré l'œil: le défi Captcha game, proposé par l'entreprise Viveris.

Voici sa description :

Concevoir un mini-jeu qui s'inspire de l'univers du gaming pour remplacer les CAPTCHA traditionnels. Ce défi vous permet de mêler créativité et sécurité web, tout en plongeant les utilisateurs dans une expérience ludique et immersive. Plus qu'un simple test, faites de ce CAPTCHA un véritable jeu !

En le lisant, une révélation divine m'est alors parvenue : et si je prenais le jeu DOOM et que je le rendais directement jouable depuis le navigateur ?

Pour la résolution du captcha en lui même, un seul kill ferait l'affaire, pas besoin de faire quelque chose de plus farfelu au vu de la tâche qui n'est elle-même pas saine d'esprit ... La problématique est donc de réussir à compiler du C vers du Web ???

WASM Ange overwatch WASM qui vient à la rescousse

WASM pour WebAssembly

Le WebAssembly est une technologie datant de 2017, qui a pour objectif de faciliter la réalisation d'applications performantes sur le web. Mais il est aussi possible d'utiliser cette technologie en dehors du web. Pour vous donner une idée de ce qui a pu être réalisé avec ça, certaines personnes ont pu faire tourner des VM linux directement dans le navigateur :

  • v86 : v86 emulates an x86-compatible CPU and hardware. Vous pouvez tester par vous même ici
  • WebVM : WebVM is a server-less virtual environment running fully client-side in HTML5/WebAssembly.

Et tout ça compatible directement sur Firefox, Chrome, Safari et Edge !

Bon, c'est fabuleux tout ça, mais comment je transforme le code de ID Software en WASM ?

Clang est ton ami

Il est possible de compiler directement avec clang quelque chose du style :

int add(int a, int b) {
  return a*a + b;
}

avec

clang \
  --target=wasm32 \
  -nostdlib \
  -Wl,--no-entry \
  -Wl,--export-all \
  -o add.wasm \
  add.c

et utiliser add.wasm dans une page html comme suivant

<!DOCTYPE html>

<script type="module">
  async function init() {
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch("./add.wasm")
    );
    console.log(instance.exports.add(4, 1));
  }
  init();
</script>

Mais il y a un problème. Si vous ne le voyez pas, prenez le temps de regarder les flags passés à clang lors de la compilation. Vous l'avez ? Toujours pas ?

Ok, le soucis est qu'avec cette manière de compiler nous avons pas accès à la "stdlib" (librairie standard). Donc les fonctions que vous utilisez tout le temps lorsque vous faite du C ne sont pas disponible (malloc,read,printf ...), ce qui est vraiment contraignant. Une solution aurait pu être d'implementer une API POSIX à la main, mais n'ayant qu'une nuit blanche devant moi ça n'était (malheureusement) pas une option. Heureusement, Emscripten existe.

Emscripten est ton meilleur ami

Pour résumer, Emscripten, c'est de la pure magie noire. Non, plus sérieusement il permet de faire ce qu'on a vu précedemment avec clang, mais en nous facilitant beaucoup la vie si l'on souhaite par exemple compiler une application qui utilise OpenGL ou SDL. Emscripten va génerer le WASM et le code javascript qui va s'occuper de tout.

Emscripten est similaire aux outils utilisés en programation C/C++. gcc deviens emcc et make deviens emmake.

Tout ça se compile assez facilement. Néanmoins, il est quand même nécessaire de rajouter certains flags de compilation.

...
EMFLAGS="-gsource-map -s INVOKE_RUN=1 \
    -s USE_SDL=2 \
    -s USE_SDL_MIXER=2 \
    -s LEGACY_GL_EMULATION=0 \
    -s USE_SDL_NET=2 \
    -s ASSERTIONS=0 \
    -s WASM=1 \
    -s ALLOW_MEMORY_GROWTH=0 \
    -s FORCE_FILESYSTEM=1 \
    -s EXPORTED_RUNTIME_METHODS=[['FS','ccall']] \
    -s SAFE_HEAP=1 \
    -s EXIT_RUNTIME=1 \
    -s STACK_OVERFLOW_CHECK=1 \
    -s PROXY_POSIX_SOCKETS=0 \
    -s USE_PTHREADS=0 \
    -s PROXY_TO_PTHREAD=0 \
    -s TOTAL_MEMORY=64MB \
    -s ERROR_ON_UNDEFINED_SYMBOLS=0 \
    -sASSERTIONS \
    --preload-file /home/yanovskyy/Documents/projects/doom-wasm/data/doom2.wad@doom2.wad \
    --preload-file /home/yanovskyy/Documents/projects/doom-wasm/data/default.cfg@default.cfg \
    -s ASYNCIFY -O3 --source-map-base /"
...

Le dépôt se trouve ici (merci aux développeurs de cloudfare qui ont déjà fait une grande partie de la mise en place)

Après quelques petits soucis de versions qui provoquent des crashs mémoire, cela compile bien !

Doom sur chrome DOOM2 sur chrome via https://ratiblue.ratiscrum.fr/doom_page.html

DOOM Captcha

Maintenant que ça tourne, il faut transformer cela en captcha. L'idée est très simple : détecter la réalisation d'un kill et le captcha est in the pocket ! Mais compilé de cette manière, il n'est pas facile d'obtenir l'information du nombre de kill. Il va donc falloir écrire un peu de code supplémentaire pour récuperer cette information.

On appelle périodiquement (toutes les secondes) la fonction get_total_kills et on regarde si la valeur est supérieure à 0. Si oui, le captcha est validé, sinon rien.

...
    function getKills() {
        let kills = Module["ccall"]("get_total_kills",
            "number",
            [],
            []
        );
        if(kills > 0) {
            ...
        }
    }


    Module["onRuntimeInitialized"] = function() {
        setInterval(getKills, 500);
        Module.run(commonArgs);
    }
...

Note : La variable Module ici est l'API entre notre code Javascript et le jeu DOOM. Le ccall est une fonction utilitaire liée à Emscripten pour appeler des fonctions spécifiques comme c'est définit ici :

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int get_total_kills() {
    return players[0].killcount;
}

Il est néanmoins possible de faire mieux. Emscripten permet d'appeler directement une fonction Javascript via la macro :

EM_JS(type_de_retour, nom_de_fonction, (args), {
    action
});

Pendant la Nuit de l'info, c'est justement ce que j'ai essayé de faire :

EM_JS(void, call_js_event_kills, (int count_kills), {
	console.log("Kills: " + count_kills);
});

void
P_KillMobj
( mobj_t*	source,
  mobj_t*	target )
{
    mobjtype_t	item;
    mobj_t*	mo;

    target->flags &= ~(MF_SHOOTABLE|MF_FLOAT|MF_SKULLFLY);

    if (target->type != MT_SKULL)
		target->flags &= ~MF_NOGRAVITY;

    target->flags |= MF_CORPSE|MF_DROPOFF;
    target->height >>= 2;

    if (source && source->player)
    {
    // count for intermission
    if (target->flags & MF_COUNTKILL)
        source->player->killcount++;

    if (target->player)
        source->player->frags[target->player-players]++;
    }
    else if (!netgame && (target->flags & MF_COUNTKILL) )
    {
    // count all monster deaths,
    // even those caused by other monsters
    players[0].killcount++;
    call_js_event_kills(count_kills);
    }
....
}

Mais avec la fatigue, et le fait que je ne suis pas habitué à lire du code comme celui-ci, j'ai appelé la fonction seulement lorsque deux monstres se tuaient entre eux. Pour que ça fonctionne correctement, il aurait fallu faire :

  if (source && source->player)
  {
  // count for intermission
  if (target->flags & MF_COUNTKILL)
  {
      source->player->killcount++;
      call_js_event_kills(count_kills);
  }
  if (target->player)
      source->player->frags[target->player-players]++;
  }
  else if (!netgame && (target->flags & MF_COUNTKILL) )
  {
  // count all monster deaths,
  // even those caused by other monsters
  players[0].killcount++;
  }

Cela provoque néanmoins des pertes de performances, qui restent minimes dans notre cas. En revanche, je trouve cette manière de faire bien plus élegante que la première solution.

1 morts, 1 blessés, je pète mon crâne. Ma libellule !

Tous les élements étant réunis, la dernière étape est d'implementer tout ça sur le projet principal des Ratiscrum. Cela devait en théorie prendre 10 min, le temps de copier coller le code d'un projet à l'autre. Mais NextJS a simplement décidé de figer le navigateur lorque l'on tente d'executer Doom.

Le code javascript géneré par Emscripten faisant quand même plus de 12188 lignes de code, cela doit certainement déclencher un bridage quelque part côté NextJS. Mais avec le peu de temps à ma disposition, je n'ai pas creuser la piste plus loin. Après une nuit à tenter de batailler avec NextJS, la solution à tout ça fut finalement une simple iframe, après 8h de debuggage, d'essais et d'erreurs. :)

export default function DoomCaptcha({ onKill }: { onKill: () => void }) {
...
  return (
    <>
      <iframe
        id="doom_frame"
        src="/doom_page.html"
        width="100%"
        height="100%"
      ></iframe>
    </>
  );

Conclusion

Porter DOOM sur navigateur m'a demandé de jongler avec pas mal d'outils et de notions. Cela fut un exercice très marrant, mais utiliser une technologie comme NextJS s'est avéré très douloureux. Tellement de chose se passent sous le capot que lorsqu'on souhaite faire quelque chose d'un petit peu exotique, on peu rapidement tomber dans mon cas, comme pendant cette nuit de l'info où j'ai galéré plusieures heures pour trouver une solution. Dieu merci je suis finalement parvenu à la trouver et je suis heureux du résultat final. Je garderais tout de même un petit goût d'amertume envers NextJS qui va me rester un petit moment au moins.

Merci encore à l'IUT Robert Schuman, à l'équipe Ratiscrum et au bureau de Nuit l'info pour cette merveilleuse nuit !

Return to home