diff --git a/CREDITS.md b/CREDITS.md
index 358f87d..f7e62ec 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -41,6 +41,14 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
**Python 3** (licence PSF, compatible GPL), qui exécute le comparateur
`tests/reference/compare.py`, et **jq** (licence MIT), qui lit les JSON
dans `tests/test_reference.sh`. Aucun des deux n'est utilisé sur le PC.
+ Le serveur HTTP de Python sert aussi la page de test (`make deploy-web`).
+- **Emscripten** (licence MIT et University of Illinois/NCSA), image Docker
+ `emscripten/emsdk:3.1.64`, compile le moteur en WebAssembly
+ (`make engine-wasm`). Le code JavaScript qu'il génère
+ (`build/wasm/engine.mjs`) et sa bibliothèque C (musl, licence MIT) sont
+ inclus dans le moteur WebAssembly, licences compatibles GPLv3. L'image
+ fournit aussi **Node.js** (MIT), qui rejoue la suite de contrat sur le
+ moteur WebAssembly et lance `make bench`.
## Remerciements
diff --git a/Makefile b/Makefile
index 4b94caf..861c59e 100644
--- a/Makefile
+++ b/Makefile
@@ -76,8 +76,10 @@ deploy: ## Installe l'application sur le téléphone (pas encore disponible)
@echo "Page de test du moteur WebAssembly : make deploy-web"
@exit 1
-# Sert web/ à la racine et build/wasm/ sous /wasm/, en lecture seule, avec le
-# serveur HTTP de Python (types MIME corrects, dont application/wasm).
+# Sert web/ à la racine et build/wasm/ sous /wasm/ avec le serveur HTTP de
+# Python (types MIME corrects, dont application/wasm). Les deux dossiers sont
+# montés en lecture seule puis copiés dans le conteneur au démarrage : relancer
+# après une modification.
# L'adresse affichée est celle du PC sur l'interface de sa route par défaut,
# c'est-à-dire sur le réseau local (Wi-Fi ou câble).
deploy-web: engine-wasm ## Sert la page de test WebAssembly sur le réseau local (Ctrl+C pour arrêter)
@@ -86,8 +88,9 @@ deploy-web: engine-wasm ## Sert la page de test WebAssembly sur le réseau local
echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/bench.html"; \
echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter."
@docker run --rm --init -p $(WEB_PORT):8080 \
- -v "$(CURDIR)/web:/site:ro" -v "$(CURDIR)/build/wasm:/site/wasm:ro" \
- $(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site
+ -v "$(CURDIR)/web:/web:ro" -v "$(CURDIR)/build/wasm:/wasm:ro" $(BUILD_C_IMAGE) \
+ sh -c 'cp -r /web /tmp/site && cp -r /wasm /tmp/site/wasm \
+ && exec python3 -m http.server 8080 --directory /tmp/site'
clean: ## Supprime les artefacts de build (garde les images Docker)
rm -rf $(BUILD_DIR)
diff --git a/docs/guide-test-etape-0.md b/docs/guide-test-etape-0.md
new file mode 100644
index 0000000..0b39e69
--- /dev/null
+++ b/docs/guide-test-etape-0.md
@@ -0,0 +1,83 @@
+# Guide de test : le moteur sur le Fairphone 4 (étape 0b)
+
+Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
+
+Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
+calcule, puis lancer une mesure et coller son rapport dans la conversation.
+Comptez un quart d'heure.
+
+## 1. Préparer le PC
+
+1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
+2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
+ outils. La première fois, c'est long ; ensuite, c'est immédiat.
+3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
+
+## 2. Lancer la page
+
+1. Tapez `make deploy-web` puis Entrée.
+2. La première fois, le moteur se compile : quelques minutes.
+3. Le terminal affiche deux adresses, par exemple :
+
+ ```
+ Page de test : http://192.168.1.21:8080/
+ Page de mesure : http://192.168.1.21:8080/bench.html
+ ```
+
+ Vos chiffres peuvent être différents : notez ceux de votre écran.
+4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
+ fin : Ctrl+C dans le terminal.
+
+## 3. Essayer un meilleur coup
+
+1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
+2. Tapez l'adresse de la **page de test** dans la barre d'adresse, exactement
+ comme affichée (avec `http://` et `:8080`).
+3. Attendez le message « Moteur prêt, chargé en … ms ».
+4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
+5. Dans « Niveau », choisissez « fort (2 plis) ».
+6. Touchez « Meilleur coup ».
+7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
+ si vous voulez, il sera aussi dans le rapport.
+
+## 4. Lancer la mesure
+
+1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
+ page de mesure).
+2. Touchez « Lancer la mesure ».
+3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
+ gardez l'écran allumé et la page ouverte, sans changer d'application.
+4. Un tableau résume les résultats : « OK » si le seuil est respecté,
+ « dépassé » sinon.
+
+## 5. Envoyer le rapport
+
+1. Touchez « Copier les résultats ».
+2. Si la page dit « Rapport copié », c'est bon.
+3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
+ puis « Copier ».
+4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
+ ou envoyez-le vous sur le PC).
+
+Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
+page de mesure) et collez aussi ce second rapport : cela montre si les temps
+sont stables.
+
+## Si la page ne s'ouvre pas
+
+- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
+ et `:8080` à la fin.
+- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
+ pas sur la 4G ni sur un Wi-Fi invité.
+- **Vérifiez que la page tourne** : sur le PC, ouvrez
+ `http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
+ plus, relancez `make deploy-web` et lisez le message d'erreur.
+- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
+ (`sudo ufw status` affiche « Status: active »), autorisez le port le temps
+ du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
+ 8080/tcp`.
+- **« Le moteur n'a pas pu se charger »** : notez le message exact et
+ collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
+ Firefox), à jour.
+- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
+ `make deploy-web WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
diff --git a/engine-wasm/bench.mjs b/engine-wasm/bench.mjs
new file mode 100644
index 0000000..dc2b53c
--- /dev/null
+++ b/engine-wasm/bench.mjs
@@ -0,0 +1,44 @@
+// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC
+// (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte
+// est celui du téléphone.
+//
+// Copyright (C) 2026 les auteurs d'Ustagammon.
+// Licence : GPLv3 ou ultérieure (voir LICENSE).
+
+import { readFileSync } from "node:fs";
+import os from "node:os";
+import { fileURLToPath } from "node:url";
+import { gzipSync } from "node:zlib";
+import createEngineModule from "../build/wasm/engine.mjs";
+import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/benchmark.mjs";
+import { commands, loadEngine } from "../web/engine-wasm.mjs";
+
+const root = new URL("../", import.meta.url);
+const wasmDir = fileURLToPath(new URL("build/wasm/", root));
+
+// Les fichiers du site : web/, et build/wasm/ servi sous wasm/.
+function sitePath(name) {
+ const path = name.startsWith("wasm/") ? "build/" + name : "web/" + name;
+ return fileURLToPath(new URL(path, root));
+}
+
+const files = DOWNLOADED_FILES.map((name) => {
+ const content = readFileSync(sitePath(name));
+ return { name, bytes: content.length, gzipBytes: gzipSync(content).length };
+});
+
+const start = performance.now();
+const engine = await loadEngine(createEngineModule, wasmDir);
+const loadMs = performance.now() - start;
+
+const { positions } = JSON.parse(readFileSync(sitePath("wasm/positions.json"), "utf8"));
+const times = await measureTimes({
+ bestMove: (...args) => engine.run(commands.bestMove(...args)),
+ cubeDecision: (...args) => engine.run(commands.cubeDecision(...args)),
+}, positions, (done, total) => process.stderr.write("\r " + done + "/" + total + " calculs"));
+process.stderr.write("\n");
+
+const environment = "Node " + process.version + ", " + os.cpus()[0].model + " (PC de développement)";
+process.stdout.write(formatReport({
+ environment, files, loadMs, memoryBytes: engine.memoryBytes(), times,
+}));
diff --git a/web/bench-page.mjs b/web/bench-page.mjs
new file mode 100644
index 0000000..eab399b
--- /dev/null
+++ b/web/bench-page.mjs
@@ -0,0 +1,97 @@
+// bench-page.mjs -- page de mesure (bench.html) : chronomètre le moteur
+// dans un Web Worker et produit le rapport à copier.
+//
+// Copyright (C) 2026 les auteurs d'Ustagammon.
+// Licence : GPLv3 ou ultérieure (voir LICENSE).
+
+import { DOWNLOADED_FILES, DOWNLOAD_LIMIT_BYTES, formatReport, measureTimes, summarize }
+ from "./benchmark.mjs";
+import { startEngine } from "./engine-client.mjs";
+
+const $ = (id) => document.getElementById(id);
+
+// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
+// serveur qui compresse ses réponses).
+async function fileSize(name) {
+ const blob = await (await fetch(name, { cache: "no-store" })).blob();
+ const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip"));
+ const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength;
+ return { name, bytes: blob.size, gzipBytes };
+}
+
+function addRow(cells) {
+ const row = $("table").insertRow();
+ for (const text of cells) {
+ row.insertCell().textContent = text;
+ }
+}
+
+async function measure() {
+ $("start").disabled = true;
+ $("results").hidden = true;
+ $("table").replaceChildren();
+
+ $("status").textContent = "Chargement du moteur…";
+ const start = performance.now();
+ const engine = startEngine();
+ await engine.ready;
+ const loadMs = performance.now() - start;
+
+ const { positions } = await (await fetch("wasm/positions.json")).json();
+ let memoryBytes = 0;
+ const times = await measureTimes({
+ bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
+ cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory),
+ }, positions, (done, total) => {
+ $("status").textContent = "Calcul " + done + " sur " + total + "…";
+ });
+ function keepMemory(answer) {
+ memoryBytes = answer.memoryBytes;
+ return answer;
+ }
+
+ $("status").textContent = "Mesure de la taille à télécharger…";
+ const files = await Promise.all(DOWNLOADED_FILES.map(fileSize));
+
+ for (const s of summarize(times)) {
+ addRow([s.label, Math.round(s.medianMs) + " ms", Math.round(s.maxMs) + " ms",
+ s.limitMs + " ms", s.ok ? "OK" : "dépassé"]);
+ }
+ const gzipTotal = files.reduce((sum, f) => sum + f.gzipBytes, 0);
+ addRow(["Taille compressée", "", (gzipTotal / 1e6).toFixed(1).replace(".", ",") + " Mo",
+ DOWNLOAD_LIMIT_BYTES / 1e6 + " Mo", gzipTotal < DOWNLOAD_LIMIT_BYTES ? "OK" : "dépassé"]);
+
+ $("report").value = formatReport({
+ environment: navigator.userAgent, files, loadMs, memoryBytes, times,
+ });
+ $("results").hidden = false;
+ $("status").textContent = "Mesure terminée.";
+}
+
+// Copie dans le presse-papiers. L'API moderne n'existe que sur les pages
+// sécurisées (https ou localhost) : sur le réseau local en http, on
+// sélectionne le texte et on utilise l'ancienne commande de copie.
+async function copyReport() {
+ try {
+ if (navigator.clipboard) {
+ await navigator.clipboard.writeText($("report").value);
+ } else {
+ $("report").select();
+ if (!document.execCommand("copy")) {
+ throw new Error();
+ }
+ }
+ $("copy-status").textContent = "Rapport copié : collez-le dans la conversation.";
+ } catch {
+ $("report").select();
+ $("copy-status").textContent = "Copie impossible : le texte est sélectionné, "
+ + "copiez-le à la main (appui long, puis Copier).";
+ }
+}
+
+$("start").onclick = () => measure().catch((error) => {
+ $("status").textContent = "La mesure a échoué : " + error.message;
+ $("status").classList.add("error");
+ $("start").disabled = false;
+});
+$("copy").onclick = copyReport;
diff --git a/web/bench.html b/web/bench.html
new file mode 100644
index 0000000..ca4d92a
--- /dev/null
+++ b/web/bench.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+Ustagammon : mesure du moteur
+
+
+
+
+
+
Mesure du moteur
+
Cette page chronomètre le moteur sur les positions de référence : meilleur coup
+ aux niveaux rapide et fort, décision de videau au niveau fort. Elle mesure aussi
+ la taille à télécharger et la mémoire utilisée. Comptez une à quelques minutes ;
+ gardez l'écran allumé et la page au premier plan.