From 850de27e90cce31d52044e24fdd810d72a925d80 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:00:28 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Plan=20de=20l'=C3=A9tape=200b=20:=20moteur?= =?UTF-8?q?=20en=20WebAssembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- docs/plans/2026-11-02-moteur-webassembly.md | 92 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-11-02-moteur-webassembly.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index c63633b..1f359d6 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -16,7 +16,7 @@ automatique aux références, tolérance 0,002 (0a.4). | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0b | Moteur en WebAssembly, page de test dans un Web Worker, `make deploy-web`, `make bench` | ⏳ | | +| 0b | Moteur en WebAssembly, page de test dans un Web Worker, `make deploy-web`, `make bench` | 🔄 | `plans/2026-11-02-moteur-webassembly.md` | | Mesures | Pierre relève les mesures sur le Fairphone 4 | ⏳ | | | Décision | Socle PWA ou Android natif, consigné dans un ADR — validation de Pierre | ⏳ | | | 0c | Android natif (NDK, Gradle en ligne de commande, `make deploy` via adb) | ⏸ si 0b échoue | | diff --git a/docs/plans/2026-11-02-moteur-webassembly.md b/docs/plans/2026-11-02-moteur-webassembly.md new file mode 100644 index 0000000..b4f7f3b --- /dev/null +++ b/docs/plans/2026-11-02-moteur-webassembly.md @@ -0,0 +1,92 @@ +# Moteur en WebAssembly : page de test, contrat et mesures + +- **Date** : 2026-11-02 +- **Étape** : 0b +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree isolé + +## Objectif + +Le même moteur (`engine/`, `engine.h`) compilé en WebAssembly tourne dans un +Web Worker d'une page web minimale servie sur le réseau local. Il donne les +mêmes résultats que le moteur natif sur les 32 positions de référence, et une +page de mesure produit les chiffres qui permettront de trancher entre PWA et +Android natif (`docs/etape-0.md`, section 0b). + +## Consignes + +1. **Compilation** : `make engine-wasm` compile le moteur avec Emscripten, dans + l'image `emscripten/emsdk:3.1.64` (déjà téléchargée ; version figée), + en C générique (pas de SIMD), sans threads, `-j2`. Sorties dans + `build/wasm/`. Les données (`gnubg.weights` ou `gnubg.wd`, bases `.bd`, + table d'équité) viennent de `build/data/` produit par `make engine`, et sont + chargées une fois (système de fichiers virtuel d'Emscripten ou équivalent + simple). Recompilation minimale si rien n'a changé. Cible visible dans + `make help`. +2. **Interface JavaScript minimale** au-dessus d'`engine.h` (par exemple + `bestMoves`, `cubeDecision`, `evaluate`, `reference`), qui renvoie les mêmes + objets JSON que `engine-cli`. Réutiliser le code de mise en JSON + d'`engine-cli` plutôt que de le réécrire, si c'est simple. +3. **Même suite de contrat** (ADR 0007, point 7) : rejouer les 32 positions de + référence aux niveaux 0 et 2 plis avec le build WebAssembly, sous Node (dans + l'image Emscripten, qui contient Node, ou une image Node figée), avec le + même comparateur `tests/reference/compare.py` et la même tolérance. Le plus + simple : un petit programme Node qui imite l'interface en ligne de commande + d'`engine-cli`, et une option du comparateur pour choisir le programme + interrogé. Test lancé par `make test` (`tests/test_wasm_reference.sh`). +4. **Page de test `web/`** (HTML, CSS et JavaScript sans framework ni étape de + build) : moteur chargé dans un Web Worker, jamais sur le fil d'affichage ; + saisie d'un Position ID, d'un Match ID et des dés ; bouton « Meilleur coup » ; + affichage du résultat et du temps de calcul. Textes en français, identifiants + en anglais. +5. **Page de mesure** (dans `web/`) : sur une série de positions de référence, + temps du meilleur coup à 0 et 2 plis et de la décision de videau à 2 plis + (médiane et maximum), temps de chargement du moteur, taille totale + téléchargée (non compressée et compressée gzip), mémoire WebAssembly + utilisée. Un bouton « Copier les résultats » met un rapport texte dans le + presse-papiers, que Pierre collera dans la conversation. La page compare + elle-même aux seuils de `docs/etape-0.md`. +6. **`make deploy-web`** sert `web/` et `build/wasm/` sur le réseau local + (serveur dans Docker, rien sur le PC ; types MIME corrects dont + `application/wasm`), et affiche l'adresse exacte à ouvrir depuis le + téléphone (IP du PC sur le réseau local). Le serveur s'arrête par Ctrl+C. + Mettre à jour la cible `deploy` si pertinent. +7. **`make bench`** : la même mesure que la page, sous Node, sur le PC, pour un + premier ordre de grandeur (le chiffre qui compte reste celui du téléphone). +8. **Guide de test** pour Pierre : `docs/guide-test-etape-0.md` (document + temporaire de l'étape 0), pas à pas : préparer le PC, lancer + `make deploy-web`, ouvrir l'adresse sur le Fairphone 4 (même Wi-Fi), faire un + essai de meilleur coup, lancer la page de mesure, copier le rapport et le + coller dans la conversation. Phrases courtes, aucune connaissance technique + supposée, que faire si la page ne s'ouvre pas (pare-feu, réseau). + +Contraintes : +- respecter `CLAUDE.md` et les ADR 0004, 0005, 0007 : simplicité, pas de + framework, identifiants en anglais, commentaires et textes en français, tout + outil dans Docker ; +- ne pas modifier `engine/gnubg/` ni les références ; toute adaptation du + moteur passe par `engine/compat/` ou le code d'Ustagammon ; +- signaler chaque nouvelle dépendance et sa licence dans `CREDITS.md` + (Emscripten : MIT/NCSA) ; +- `make lint` et `make test` doivent passer ; commits petits, en français, avec + la ligne `Co-Authored-By: Claude Opus 5.5 ` ; +- ne pas pousser ni créer de merge request ; +- ne rien simuler : les mesures viennent d'exécutions réelles. + +Rapport attendu : ce qui a été fait, commandes de vérification, taille des +fichiers téléchargés, mesures `make bench` sur le PC, résultat de la suite de +contrat en WebAssembly (écart maximal), difficultés, écarts au plan. + +## Critères de réussite + +- `make engine-wasm` puis `make test` : les 32 positions concordent en + WebAssembly avec la même tolérance qu'en natif. +- `make deploy-web` affiche une adresse ; la page donne le meilleur coup d'une + position de référence avec son temps de calcul (vérifié dans un navigateur + sans interface si possible, sinon par une requête HTTP sur les fichiers). +- `make bench` produit un rapport. +- `make lint` et `make test` passent. + +## Résultat + +À remplir à la clôture. From b0030521d80445e0b1a813bc8597be7749c9b482 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:07:01 +0100 Subject: [PATCH 2/5] Moteur en WebAssembly : make engine-wasm et suite de contrat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le moteur et engine-cli sont compilés avec Emscripten (image figée emscripten/emsdk:3.1.64), en C générique, sans threads ; les données de build/data/ sont incluses dans engine.data. Le JavaScript appelle engine_init une fois puis main pour chaque commande : la sortie est le JSON d'engine-cli, sans code de mise en forme dupliqué. tests/test_wasm_reference.sh rejoue les 32 positions de référence sous Node avec le même comparateur (option --cli) et la même tolérance. Co-Authored-By: Claude Opus 5.5 --- Makefile | 33 ++++++++++++++- engine-cli/main.c | 7 ++++ engine-wasm/engine-cli.mjs | 24 +++++++++++ engine-wasm/wasm.mk | 80 ++++++++++++++++++++++++++++++++++++ engine/engine.mk | 7 +--- engine/sources.mk | 11 +++++ tests/reference/compare.py | 12 +++++- tests/test_wasm_reference.sh | 22 ++++++++++ web/engine-wasm.mjs | 78 +++++++++++++++++++++++++++++++++++ 9 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 engine-wasm/engine-cli.mjs create mode 100644 engine-wasm/wasm.mk create mode 100644 engine/sources.mk create mode 100644 tests/test_wasm_reference.sh create mode 100644 web/engine-wasm.mjs diff --git a/Makefile b/Makefile index 3163d3f..4b94caf 100644 --- a/Makefile +++ b/Makefile @@ -18,14 +18,19 @@ REFERENCE_IMAGE := ustagammon/gnubg-reference:14b6798 # Chaîne de compilation C (docker/build-c/Dockerfile) : changer l'étiquette # à chaque modification du Dockerfile, pour que make setup la reconstruise. BUILD_C_IMAGE := ustagammon/build-c:2 +# Emscripten (compilation WebAssembly ; contient aussi Node et Python 3). +WASM_IMAGE := emscripten/emsdk:3.1.64 +# Port de la page de test servie sur le réseau local (make deploy-web). +WEB_PORT := 8080 -export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE +export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE # Lance une commande dans la chaîne C, sous l'utilisateur courant pour que # build/ lui appartienne. DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(BUILD_C_IMAGE) +DOCKER_WASM := docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp -v "$(CURDIR):/src" -w /src $(WASM_IMAGE) -.PHONY: help setup build engine test lint deploy clean reference-image reference +.PHONY: help setup build engine engine-wasm bench test lint deploy deploy-web clean reference-image reference help: ## Liste les commandes disponibles @echo "Ustagammon : commandes disponibles" @@ -41,6 +46,8 @@ setup: ## Installe la chaîne d'outils (images Docker) ; sans effet si déjà fa @$(MAKE) reference-image @docker image inspect $(BUILD_C_IMAGE) >/dev/null 2>&1 \ || docker build -t $(BUILD_C_IMAGE) docker/build-c + @docker image inspect $(WASM_IMAGE) >/dev/null 2>&1 \ + || docker pull $(WASM_IMAGE) @echo "Chaîne d'outils prête." build: engine ## Compile le moteur (si nécessaire) puis l'application @@ -50,6 +57,14 @@ engine: ## Compile le moteur, ses données et build/engine-cli (dans Docker) || { echo "Chaîne C absente : lancer d'abord make setup"; exit 1; } @$(DOCKER_C) make -f engine/engine.mk -j2 --no-print-directory +engine-wasm: engine ## Compile le moteur en WebAssembly dans build/wasm/ (dans Docker) + @docker image inspect $(WASM_IMAGE) >/dev/null 2>&1 \ + || { echo "Emscripten absent : lancer d'abord make setup"; exit 1; } + @$(DOCKER_WASM) make -f engine-wasm/wasm.mk -j2 --no-print-directory + +bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport texte) + @$(DOCKER_WASM) node engine-wasm/bench.mjs + test: ## Lance tous les tests @tests/run.sh @@ -58,8 +73,22 @@ lint: ## Vérifie le style et les erreurs courantes deploy: ## Installe l'application sur le téléphone (pas encore disponible) @echo "Pas encore disponible : le socle (PWA ou Android) sera choisi à la fin de l'étape 0." + @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). +# 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) + @ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \ + echo "Page de test : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \ + 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 + clean: ## Supprime les artefacts de build (garde les images Docker) rm -rf $(BUILD_DIR) diff --git a/engine-cli/main.c b/engine-cli/main.c index dddcb9b..f32f293 100644 --- a/engine-cli/main.c +++ b/engine-cli/main.c @@ -22,6 +22,11 @@ * --ply : niveau de gnubg, de 0 (défaut) à 4. * --data : dossier des données ; par défaut « data » à côté du programme. * + * En WebAssembly (engine-wasm/), le même programme sert d'interface : le + * JavaScript appelle main pour chaque commande, sans recharger le moteur. + * engine_init ne fait rien quand le moteur est déjà prêt, et main ne l'arrête + * pas à la fin. + * * Sortie : un objet JSON sur la sortie standard. En cas d'erreur, un objet * {"error": {"code": ..., "message": ...}} et un code de sortie non nul : * 1 pour une erreur du moteur, 2 pour une erreur d'utilisation. @@ -391,6 +396,8 @@ main(int argc, char **argv) else result = usage_error("commande inconnue : bestmove, moves, legal, cube, eval ou reference"); +#ifndef __EMSCRIPTEN__ engine_shutdown(); +#endif return result; } diff --git a/engine-wasm/engine-cli.mjs b/engine-wasm/engine-cli.mjs new file mode 100644 index 0000000..4a8e091 --- /dev/null +++ b/engine-wasm/engine-cli.mjs @@ -0,0 +1,24 @@ +// engine-cli.mjs -- engine-cli, mais avec le moteur WebAssembly, sous Node. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Mêmes arguments, même sortie et mêmes codes de sortie que build/engine-cli +// (voir engine-cli/main.c) : c'est le même main, compilé en WebAssembly. +// Sert à rejouer la suite de contrat sur le build WebAssembly +// (tests/test_wasm_reference.sh) : +// +// node engine-wasm/engine-cli.mjs --ply 2 reference 4HPwATDgc/ABMA cIkFAAAAAAAA 31 +// +// L'option --data est ignorée : les données sont incluses dans +// build/wasm/engine.data. + +import { fileURLToPath } from "node:url"; +import createEngineModule from "../build/wasm/engine.mjs"; +import { loadEngine } from "../web/engine-wasm.mjs"; + +const wasmDir = fileURLToPath(new URL("../build/wasm/", import.meta.url)); +const engine = await loadEngine(createEngineModule, wasmDir); +const { text, exitCode } = engine.runText(process.argv.slice(2)); +console.log(text); +process.exitCode = exitCode; diff --git a/engine-wasm/wasm.mk b/engine-wasm/wasm.mk new file mode 100644 index 0000000..478333e --- /dev/null +++ b/engine-wasm/wasm.mk @@ -0,0 +1,80 @@ +# wasm.mk -- compilation du moteur en WebAssembly avec Emscripten. +# +# Lancé dans l'image Docker emscripten/emsdk par `make engine-wasm` (Makefile +# racine), depuis la racine du dépôt, après `make engine` qui produit les +# données dans build/data/. Tout est produit dans build/wasm/ : +# +# engine.mjs module JavaScript (ES) qui charge le WebAssembly +# engine.wasm le moteur et engine-cli compilés : mêmes sources qu'en +# natif (engine/engine.mk), en C générique, sans threads +# engine.data les données de build/data/, chargées une fois dans le +# système de fichiers virtuel d'Emscripten, sous /data +# positions.json les positions de référence, pour la page de mesure +# +# Le JavaScript appelle engine_init une fois, puis main (celui +# d'engine-cli/main.c) pour chaque commande : voir web/engine-wasm.mjs. + +BUILD := build +OUT := $(BUILD)/wasm +GNUBG := engine/gnubg +COMPAT := engine/compat + +CC := emcc + +include engine/sources.mk + +# Mêmes options que le build natif : le moteur calcule comme en natif. +OPTIMISATION := -O3 -ffast-math -DG_DISABLE_ASSERT +OUR_WARNINGS := -std=c11 -Wall -Wextra -Werror +ENGINE_INCLUDES := -I$(COMPAT) -I$(COMPAT)/glib-shim -I$(GNUBG) -I$(GNUBG)/lib -Iengine/include + +# Édition des liens : +# - module ES « createEngineModule », utilisable dans un Web Worker et sous Node ; +# - main n'est pas lancé au chargement, et le programme ne s'arrête pas à sa +# sortie : le moteur reste chargé entre deux commandes ; +# - mémoire extensible : les bases de fin de partie occupent plusieurs +# mégaoctets. +LINK_FLAGS := -O3 \ + -sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createEngineModule \ + -sENVIRONMENT=web,worker,node \ + -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 \ + -sALLOW_MEMORY_GROWTH \ + -sEXPORTED_FUNCTIONS=_main,_engine_init \ + -sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8 \ + --preload-file $(BUILD)/data@/data + +DATA := $(BUILD)/data/gnubg.wd $(BUILD)/data/gnubg_os0.bd $(BUILD)/data/gnubg_ts0.bd \ + $(BUILD)/data/Kazaross-XG2.xml + +OBJECTS := $(GNUBG_ENGINE:%.c=$(OUT)/obj/gnubg/%.o) \ + $(OUT)/obj/compat/glib-shim.o $(OUT)/obj/compat/gnubg-support.o \ + $(OUT)/obj/engine.o $(OUT)/obj/engine-cli/main.o + +.PHONY: all +all: $(OUT)/engine.mjs $(OUT)/positions.json + +$(OUT)/obj/gnubg/%.o: $(GNUBG)/%.c + @mkdir -p $(@D) + $(CC) $(OPTIMISATION) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@ + +$(OUT)/obj/compat/%.o: $(COMPAT)/%.c + @mkdir -p $(@D) + $(CC) $(OPTIMISATION) $(OUR_WARNINGS) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@ + +$(OUT)/obj/engine.o: engine/engine.c + @mkdir -p $(@D) + $(CC) $(OPTIMISATION) $(OUR_WARNINGS) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@ + +$(OUT)/obj/engine-cli/%.o: engine-cli/%.c + @mkdir -p $(@D) + $(CC) $(OPTIMISATION) $(OUR_WARNINGS) -Iengine/include -MMD -MP -c $< -o $@ + +# engine.wasm et engine.data sont produits en même temps qu'engine.mjs. +$(OUT)/engine.mjs: $(OBJECTS) $(DATA) engine-wasm/wasm.mk + $(CC) $(OBJECTS) $(LINK_FLAGS) -o $@ + +$(OUT)/positions.json: tests/reference/positions.json + @mkdir -p $(@D) + cp $< $@ + +-include $(shell find $(OUT) -name '*.d' 2>/dev/null) diff --git a/engine/engine.mk b/engine/engine.mk index d163d83..ee68b92 100644 --- a/engine/engine.mk +++ b/engine/engine.mk @@ -35,11 +35,8 @@ HOST_INCLUDES := -I$(COMPAT) -I$(GNUBG) -I$(GNUBG)/lib $(shell pkg-config --cfla HOST_DEFINES := -DLOCALEDIR=\"/usr/share/locale\" HOST_LIBS := $(shell pkg-config --libs glib-2.0) -lm -# Fichiers de gnubg compilés dans le moteur (liste : engine/gnubg/PROVENANCE.md). -GNUBG_CORE := eval.c positionid.c matchid.c matchequity.c mec.c bearoff.c \ - bearoffgammon.c mtsupport.c lib/neuralnet.c lib/neuralnetsse.c lib/inputs.c \ - lib/cache.c lib/list.c lib/isaac.c lib/md5.c -GNUBG_ENGINE := $(GNUBG_CORE) drawboard.c +# Fichiers de gnubg compilés dans le moteur : GNUBG_CORE et GNUBG_ENGINE. +include engine/sources.mk ENGINE_OBJECTS := $(GNUBG_ENGINE:%.c=$(BUILD)/obj/gnubg/%.o) \ $(BUILD)/obj/compat/glib-shim.o $(BUILD)/obj/compat/gnubg-support.o \ diff --git a/engine/sources.mk b/engine/sources.mk new file mode 100644 index 0000000..dac37b6 --- /dev/null +++ b/engine/sources.mk @@ -0,0 +1,11 @@ +# sources.mk -- fichiers de gnubg compilés dans le moteur (liste commentée : +# engine/gnubg/PROVENANCE.md). Partagé par la compilation native +# (engine/engine.mk) et WebAssembly (engine-wasm/wasm.mk). + +# Cœur de gnubg, aussi lié aux outils hôte qui génèrent les données. +GNUBG_CORE := eval.c positionid.c matchid.c matchequity.c mec.c bearoff.c \ + bearoffgammon.c mtsupport.c lib/neuralnet.c lib/neuralnetsse.c lib/inputs.c \ + lib/cache.c lib/list.c lib/isaac.c lib/md5.c + +# Fichiers du moteur embarqué. +GNUBG_ENGINE := $(GNUBG_CORE) drawboard.c diff --git a/tests/reference/compare.py b/tests/reference/compare.py index 6e1f90a..a9e4ec2 100644 --- a/tests/reference/compare.py +++ b/tests/reference/compare.py @@ -11,12 +11,19 @@ tests/test_engine_reference.sh) : python3 tests/reference/compare.py [--cli build/engine-cli] [--results tests/reference/results] [--tolerance 0.002] [--details] + +--cli choisit le programme interrogé, qui doit imiter engine-cli ; ce peut +être une commande avec ses arguments. Pour le moteur WebAssembly +(tests/test_wasm_reference.sh, dans l'image Emscripten qui contient Node) : + + python3 tests/reference/compare.py --cli "node engine-wasm/engine-cli.mjs" """ import argparse import glob import json import os +import shlex import subprocess import sys @@ -55,7 +62,7 @@ class Report: def run_engine(cli, position, ply): - command = [cli, "--ply", str(ply), "reference", position["position_id"], + command = shlex.split(cli) + ["--ply", str(ply), "reference", position["position_id"], position["match_id"]] dice = position["context"]["dice"] if dice: @@ -142,7 +149,8 @@ def print_table(lines): def main(): here = os.path.dirname(os.path.abspath(__file__)) parser = argparse.ArgumentParser(description="Compare engine-cli aux références de gnubg.") - parser.add_argument("--cli", default="build/engine-cli") + parser.add_argument("--cli", default="build/engine-cli", + help="programme interrogé, éventuellement avec ses arguments") parser.add_argument("--results", default=os.path.join(here, "results")) parser.add_argument("--tolerance", type=float, default=TOLERANCE) parser.add_argument("--details", action="store_true", diff --git a/tests/test_wasm_reference.sh b/tests/test_wasm_reference.sh new file mode 100644 index 0000000..0c616be --- /dev/null +++ b/tests/test_wasm_reference.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Même suite de contrat que tests/test_engine_reference.sh (ADR 0007, point 7), +# rejouée sur le moteur compilé en WebAssembly : les 32 positions de +# référence aux niveaux 0 et 2 plis, avec le même comparateur +# (tests/reference/compare.py) et la même tolérance. Le moteur WebAssembly est +# interrogé sous Node par engine-wasm/engine-cli.mjs, qui imite engine-cli. +# Tout tourne dans l'image Emscripten, qui contient Node et Python 3. +# +# Lance `make engine-wasm` (sans effet si tout est à jour). Les arguments sont +# passés au comparateur, par exemple --details pour voir tous les nombres. + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${WASM_IMAGE:?lancer via make test}" + +# Lancé depuis `make test` : repartir d'un make indépendant du make parent. +unset MAKEFLAGS MFLAGS MAKELEVEL +make engine-wasm >/dev/null + +docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src:ro" -w /src "$image" \ + python3 tests/reference/compare.py --cli "node engine-wasm/engine-cli.mjs" "$@" diff --git a/web/engine-wasm.mjs b/web/engine-wasm.mjs new file mode 100644 index 0000000..1b0fca7 --- /dev/null +++ b/web/engine-wasm.mjs @@ -0,0 +1,78 @@ +// engine-wasm.mjs -- le moteur WebAssembly vu depuis JavaScript. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le WebAssembly (build/wasm/, voir engine-wasm/wasm.mk) contient le moteur +// et le programme engine-cli. On charge le module une fois, on initialise le +// moteur, puis chaque commande est un appel à main avec les mêmes arguments +// qu'engine-cli en ligne de commande : la sortie est donc le même JSON. +// +// Utilisé par le Web Worker de la page (engine-worker.mjs) et, sous Node, +// par engine-wasm/engine-cli.mjs et engine-wasm/bench.mjs. Le moteur calcule +// sur le fil qui l'appelle : dans le navigateur, toujours dans un Worker. + +// Dossier des données dans le système de fichiers virtuel (--preload-file). +const DATA_DIR = "/data"; + +// createEngineModule : la fonction exportée par build/wasm/engine.mjs. +// wasmDir : où trouver engine.wasm et engine.data, terminé par « / » (une +// adresse dans le navigateur, un chemin de fichier sous Node). +// Renvoie le moteur prêt, avec : +// - runText(args) : { text, exitCode }, la sortie brute d'engine-cli ; +// - run(args) : { result, ms }, le JSON décodé et le temps de calcul +// (lève une erreur si le moteur en signale une) ; +// - memoryBytes() : taille actuelle de la mémoire WebAssembly. +export async function loadEngine(createEngineModule, wasmDir) { + let output = []; + const module = await createEngineModule({ + locateFile: (name) => wasmDir + name, + print: (line) => output.push(line), + printErr: (line) => output.push(line), + }); + + const status = module.ccall("engine_init", "number", ["string"], [DATA_DIR]); + if (status !== 0) { + throw new Error("le moteur n'a pas pu charger ses données (code " + status + ")"); + } + + function runText(args) { + output = []; + const exitCode = module.callMain(args.map(String)); + return { text: output.join("\n"), exitCode }; + } + + function run(args) { + const start = performance.now(); + const { text } = runText(args); + const ms = performance.now() - start; + const result = JSON.parse(text); + if (result.error) { + throw new Error(result.error.message); + } + return { result, ms }; + } + + return { + runText, + run, + memoryBytes: () => module.HEAPU8.length, + }; +} + +// Arguments d'engine-cli pour chaque fonction de l'interface. Les dés sont +// un tableau [3, 1] ou une chaîne « 31 ». +export const commands = { + bestMove: (positionId, matchId, dice, ply) => + ["--ply", ply, "bestmove", positionId, matchId, diceText(dice)], + cubeDecision: (positionId, matchId, ply) => + ["--ply", ply, "cube", positionId, matchId], + evaluate: (positionId, matchId, ply) => + ["--ply", ply, "eval", positionId, matchId], + reference: (positionId, matchId, dice, ply) => + ["--ply", ply, "reference", positionId, matchId, ...(dice ? [diceText(dice)] : [])], +}; + +function diceText(dice) { + return Array.isArray(dice) ? dice.join("") : String(dice); +} From 120367e6d364e14c55e710d43f376033b146dcac Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:13:04 +0100 Subject: [PATCH 3/5] Page de test et page de mesure du moteur WebAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web/ : le moteur tourne dans un Web Worker (module ES) ; la page de test donne le meilleur coup d'une position et son temps de calcul ; la page de mesure chronomètre les positions de référence, mesure la taille téléchargée et la mémoire, compare aux seuils de l'étape 0b et produit un rapport à copier. make bench fait la même mesure sous Node, make deploy-web sert les pages sur le réseau local. Guide de test pour le Fairphone 4 dans docs/guide-test-etape-0.md. Co-Authored-By: Claude Opus 5.5 --- CREDITS.md | 8 +++ Makefile | 11 +-- docs/guide-test-etape-0.md | 83 ++++++++++++++++++++++ engine-wasm/bench.mjs | 44 ++++++++++++ web/bench-page.mjs | 97 ++++++++++++++++++++++++++ web/bench.html | 35 ++++++++++ web/benchmark.mjs | 138 +++++++++++++++++++++++++++++++++++++ web/engine-client.mjs | 65 +++++++++++++++++ web/engine-worker.mjs | 38 ++++++++++ web/index.html | 48 +++++++++++++ web/page.mjs | 71 +++++++++++++++++++ web/style.css | 113 ++++++++++++++++++++++++++++++ 12 files changed, 747 insertions(+), 4 deletions(-) create mode 100644 docs/guide-test-etape-0.md create mode 100644 engine-wasm/bench.mjs create mode 100644 web/bench-page.mjs create mode 100644 web/bench.html create mode 100644 web/benchmark.mjs create mode 100644 web/engine-client.mjs create mode 100644 web/engine-worker.mjs create mode 100644 web/index.html create mode 100644 web/page.mjs create mode 100644 web/style.css 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.

+ +

+ + + +

Page de test

+
+ + diff --git a/web/benchmark.mjs b/web/benchmark.mjs new file mode 100644 index 0000000..ee0aa86 --- /dev/null +++ b/web/benchmark.mjs @@ -0,0 +1,138 @@ +// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de +// mesure (bench.html, dans le navigateur) et `make bench` (sous Node). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Sur les positions de référence (tests/reference/positions.json) : temps du +// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la +// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils +// de l'étape 0b (docs/etape-0.md). + +// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur +// toutes les positions, est en dessous (la médiane est donnée en plus). +export const TIME_THRESHOLDS = [ + { key: "move0", label: "Meilleur coup, 0 pli", limitMs: 300 }, + { key: "move2", label: "Meilleur coup, 2 plis", limitMs: 3000 }, + { key: "cube2", label: "Décision de videau, 2 plis", limitMs: 3000 }, +]; + +// Taille totale à télécharger, compressée : moins de 40 Mo. +export const DOWNLOAD_LIMIT_BYTES = 40e6; + +// Fichiers téléchargés pour faire tourner le moteur dans la page, chemins +// relatifs à la racine du site (web/ ; wasm/ est build/wasm/). +export const DOWNLOADED_FILES = [ + "index.html", + "style.css", + "page.mjs", + "engine-client.mjs", + "engine-worker.mjs", + "engine-wasm.mjs", + "wasm/engine.mjs", + "wasm/engine.wasm", + "wasm/engine.data", +]; + +// engine : objet avec bestMove(positionId, matchId, dice, ply) et +// cubeDecision(positionId, matchId, ply), qui renvoient (ou promettent) +// { result, ms }. +// positions : le tableau « positions » de positions.json. +// onProgress(done, total) : appelé après chaque calcul. +// Renvoie, pour chaque clé de TIME_THRESHOLDS, la liste des +// { id, ms } mesurés. +export async function measureTimes(engine, positions, onProgress = () => {}) { + const tasks = []; + for (const p of positions) { + if (p.type === "move") { + tasks.push(["move0", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 0)]); + tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]); + } else { + tasks.push(["cube2", p, () => engine.cubeDecision(p.position_id, p.match_id, 2)]); + } + } + + const times = Object.fromEntries(TIME_THRESHOLDS.map((t) => [t.key, []])); + for (const [i, [key, position, compute]] of tasks.entries()) { + const { ms } = await compute(); + times[key].push({ id: position.id, ms }); + onProgress(i + 1, tasks.length); + } + return times; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +// Nombres à la française : virgule décimale. +function decimal(value, digits) { + return value.toFixed(digits).replace(".", ","); +} + +function megabytes(bytes) { + return decimal(bytes / 1e6, 1) + " Mo"; +} + +function verdict(ok) { + return ok ? "OK" : "DÉPASSÉ"; +} + +// Résumé d'une série de temps : { count, medianMs, maxMs, maxId, ok }. +export function summarize(times) { + return TIME_THRESHOLDS.map((threshold) => { + const list = times[threshold.key]; + const worst = list.reduce((a, b) => (b.ms > a.ms ? b : a)); + return { + ...threshold, + count: list.length, + medianMs: median(list.map((t) => t.ms)), + maxMs: worst.ms, + maxId: worst.id, + ok: worst.ms < threshold.limitMs, + }; + }); +} + +// Rapport texte, à coller dans la conversation. +// environment : description de l'appareil (navigateur ou Node) ; +// files : [{ name, bytes, gzipBytes }] ; loadMs : chargement du moteur ; +// memoryBytes : mémoire WebAssembly après la mesure ; times : measureTimes. +export function formatReport({ environment, files, loadMs, memoryBytes, times }) { + const total = files.reduce((sum, f) => sum + f.bytes, 0); + const totalGzip = files.reduce((sum, f) => sum + f.gzipBytes, 0); + const sizeOk = totalGzip < DOWNLOAD_LIMIT_BYTES; + const summary = summarize(times); + const allOk = sizeOk && summary.every((s) => s.ok); + + const lines = [ + "Ustagammon : mesure du moteur WebAssembly (étape 0b)", + "Date : " + new Date().toISOString(), + "Environnement : " + environment, + "", + "Chargement du moteur : " + Math.round(loadMs) + " ms", + "Mémoire WebAssembly après la mesure : " + megabytes(memoryBytes), + "Taille téléchargée : " + megabytes(total) + " non compressée, " + + megabytes(totalGzip) + " compressée gzip ; seuil " + + megabytes(DOWNLOAD_LIMIT_BYTES) + " compressée : " + verdict(sizeOk), + "", + ]; + for (const s of summary) { + lines.push(s.label + " (" + s.count + " positions) : médiane " + + decimal(s.medianMs, 1) + " ms, maximum " + decimal(s.maxMs, 1) + " ms (" + + s.maxId + ") ; seuil " + s.limitMs + " ms : " + verdict(s.ok)); + } + lines.push("", "Bilan : " + (allOk ? "tous les seuils sont respectés." + : "au moins un seuil est dépassé."), "", "Détail des fichiers :"); + for (const f of files) { + lines.push(" " + f.name + " : " + f.bytes + " octets, " + f.gzipBytes + " compressé gzip"); + } + lines.push("", "Détail des temps (ms) :"); + for (const s of summary) { + lines.push(" " + s.label + " : " + times[s.key] + .map((t) => t.id + " " + Math.round(t.ms)).join(", ")); + } + return lines.join("\n") + "\n"; +} diff --git a/web/engine-client.mjs b/web/engine-client.mjs new file mode 100644 index 0000000..d25459c --- /dev/null +++ b/web/engine-client.mjs @@ -0,0 +1,65 @@ +// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker +// (engine-worker.mjs) et lui envoie les commandes. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Toutes les fonctions renvoient une promesse de { result, ms, memoryBytes } : +// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le +// Worker. La promesse est rejetée si le moteur signale une erreur. + +import { commands } from "./engine-wasm.mjs"; + +export function startEngine() { + const worker = new Worker(new URL("engine-worker.mjs", import.meta.url), { type: "module" }); + const pending = new Map(); + let nextId = 1; + let resolveReady, rejectReady; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + worker.onmessage = (event) => { + const message = event.data; + if (message.type === "ready") { + resolveReady(message); + } else if (message.type === "failed") { + rejectReady(new Error(message.message)); + } else { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) { + reject(new Error(message.error)); + } else { + resolve(message); + } + } + }; + // Erreur au chargement du Worker lui-même (fichier absent, navigateur + // trop ancien pour les Workers en module ES…). + worker.onerror = (event) => { + rejectReady(new Error(event.message || "le Web Worker du moteur n'a pas démarré")); + }; + + function run(args) { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, args: args.map(String) }); + }); + } + + return { + // Promesse de { loadMs, memoryBytes } une fois le moteur chargé. + ready, + bestMove: (positionId, matchId, dice, ply) => + run(commands.bestMove(positionId, matchId, dice, ply)), + cubeDecision: (positionId, matchId, ply) => + run(commands.cubeDecision(positionId, matchId, ply)), + evaluate: (positionId, matchId, ply) => + run(commands.evaluate(positionId, matchId, ply)), + reference: (positionId, matchId, dice, ply) => + run(commands.reference(positionId, matchId, dice, ply)), + }; +} diff --git a/web/engine-worker.mjs b/web/engine-worker.mjs new file mode 100644 index 0000000..ec97e35 --- /dev/null +++ b/web/engine-worker.mjs @@ -0,0 +1,38 @@ +// engine-worker.mjs -- Web Worker qui fait tourner le moteur WebAssembly. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le moteur calcule ici, jamais sur le fil d'affichage de la page. Messages : +// - envoyé une fois au chargement : { type: "ready", loadMs, memoryBytes } +// ou { type: "failed", message } ; +// - reçu : { id, args }, les arguments d'une commande engine-cli ; +// - répondu : { id, result, ms, memoryBytes } ou { id, error }. +// Les commandes sont traitées une à une, dans l'ordre d'arrivée. + +import createEngineModule from "./wasm/engine.mjs"; +import { loadEngine } from "./engine-wasm.mjs"; + +const start = performance.now(); +const wasmDir = new URL("wasm/", import.meta.url).href; +const ready = loadEngine(createEngineModule, wasmDir); + +ready.then( + (engine) => postMessage({ + type: "ready", + loadMs: performance.now() - start, + memoryBytes: engine.memoryBytes(), + }), + (error) => postMessage({ type: "failed", message: String(error.message || error) }), +); + +onmessage = async (event) => { + const { id, args } = event.data; + try { + const engine = await ready; + const { result, ms } = engine.run(args); + postMessage({ id, result, ms, memoryBytes: engine.memoryBytes() }); + } catch (error) { + postMessage({ id, error: String(error.message || error) }); + } +}; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..26aaad9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,48 @@ + + + + + +Ustagammon : test du moteur + + + + +
+

Test du moteur

+

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

+

Chargement du moteur…

+ +
+ + + +
+ + + +
+ +
+ + + +

Page de mesure

+
+ + diff --git a/web/page.mjs b/web/page.mjs new file mode 100644 index 0000000..a4a325c --- /dev/null +++ b/web/page.mjs @@ -0,0 +1,71 @@ +// page.mjs -- page de test du moteur (index.html) : meilleur coup d'une +// position, avec son temps de calcul. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { startEngine } from "./engine-client.mjs"; + +const $ = (id) => document.getElementById(id); +const engine = startEngine(); + +engine.ready.then( + ({ loadMs }) => { + $("status").textContent = "Moteur prêt, chargé en " + Math.round(loadMs) + " ms."; + $("best-move").disabled = false; + }, + (error) => { + $("status").textContent = "Le moteur n'a pas pu se charger : " + error.message; + $("status").classList.add("error"); + }, +); + +// Exemples : les positions de référence avec des dés. +fetch("wasm/positions.json") + .then((response) => response.json()) + .then(({ positions }) => { + for (const p of positions.filter((p) => p.type === "move")) { + const option = new Option(p.id + " : " + p.description, p.id); + option.position = p; + $("example").add(option); + } + }) + .catch(() => {}); // la page reste utilisable sans les exemples + +$("example").onchange = () => { + const p = $("example").selectedOptions[0].position; + if (p) { + $("position-id").value = p.position_id; + $("match-id").value = p.match_id; + [$("die1").value, $("die2").value] = p.dice; + } +}; + +function showResult(move, details, time) { + $("move").textContent = move; + $("details").textContent = details; + $("time").textContent = time; + $("result").hidden = false; +} + +$("form").onsubmit = async (event) => { + event.preventDefault(); + $("best-move").disabled = true; + $("result").hidden = true; + try { + const { result, ms } = await engine.bestMove($("position-id").value.trim(), + $("match-id").value.trim(), [$("die1").value, $("die2").value], $("ply").value); + const best = result.best_move; + if (best) { + showResult(best.move, "Équité " + best.equity.toFixed(3).replace(".", ",") + + ", chances de gain " + (100 * best.probabilities.win).toFixed(1).replace(".", ",") + + " %, évalué à " + best.ply + " pli(s).", Math.round(ms) + " ms"); + } else { + showResult("aucun coup possible", "", Math.round(ms) + " ms"); + } + } catch (error) { + showResult("erreur", error.message, ""); + } finally { + $("best-move").disabled = false; + } +}; diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..9bdc915 --- /dev/null +++ b/web/style.css @@ -0,0 +1,113 @@ +/* Pages de test du moteur : mise en forme minimale, lisible sur téléphone. */ + +:root { + --text: #1d1d1b; + --background: #fbfaf7; + --muted: #5f5e5a; + --accent: #1f5c4a; + --error: #a3261b; + --border: #cfccc4; +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #ecebe6; + --background: #1b1c1a; + --muted: #a9a8a2; + --accent: #7cc4a8; + --error: #f08a7e; + --border: #4a4a46; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font: 17px/1.5 system-ui, sans-serif; + color: var(--text); + background: var(--background); +} + +main { + max-width: 40rem; + margin: 0 auto; + padding: 1rem; +} + +h1 { + font-size: 1.5rem; +} + +label { + display: block; + margin-bottom: 0.75rem; +} + +input, +select, +button, +textarea { + display: block; + width: 100%; + margin-top: 0.25rem; + padding: 0.5rem; + font: inherit; + color: inherit; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; +} + +.row { + display: flex; + gap: 0.75rem; +} + +.row label { + flex: 1; +} + +button { + margin: 0.5rem 0 1rem; + font-weight: 600; + color: var(--background); + background: var(--accent); + border-color: var(--accent); +} + +button:disabled { + opacity: 0.5; +} + +textarea { + height: 16rem; + font: 13px/1.4 monospace; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0.25rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.status { + color: var(--muted); +} + +.error { + color: var(--error); +} + +.result { + padding: 0.5rem 1rem; + border-left: 4px solid var(--accent); +} From d83dbfb1e5fe115c5605565cf461567ca022614c Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:20:04 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Plan=200b=20:=20r=C3=A9sultat,=20documentat?= =?UTF-8?q?ion=20de=20la=20suite=20WebAssembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/guide-test-etape-0.md | 4 +- docs/plans/2026-11-02-moteur-webassembly.md | 67 ++++++++++++++++++++- tests/reference/README.md | 9 ++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/guide-test-etape-0.md b/docs/guide-test-etape-0.md index 0b39e69..08ea57c 100644 --- a/docs/guide-test-etape-0.md +++ b/docs/guide-test-etape-0.md @@ -61,7 +61,9 @@ Comptez un quart d'heure. 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. +sont stables. Si les deux navigateurs sont installés, faites la mesure dans +Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus +lent, et le rapport indique le navigateur utilisé. ## Si la page ne s'ouvre pas diff --git a/docs/plans/2026-11-02-moteur-webassembly.md b/docs/plans/2026-11-02-moteur-webassembly.md index b4f7f3b..a135849 100644 --- a/docs/plans/2026-11-02-moteur-webassembly.md +++ b/docs/plans/2026-11-02-moteur-webassembly.md @@ -89,4 +89,69 @@ contrat en WebAssembly (écart maximal), difficultés, écarts au plan. ## Résultat -À remplir à la clôture. +**Statut : terminé** (2026-11-02, branche `wasm-travail`). Tous les critères +de réussite sont tenus ; reste la mesure sur le Fairphone 4 par Pierre +(`docs/guide-test-etape-0.md`). + +Ce qui a été fait : + +- `make engine-wasm` (`engine-wasm/wasm.mk`) compile dans + `emscripten/emsdk:3.1.64` les mêmes sources que le natif (liste commune + `engine/sources.mk`), plus `engine-cli/main.c`, en C générique, sans + threads, `-O3 -ffast-math`. Les données de `build/data/` sont embarquées + dans `engine.data` (`--preload-file`, système de fichiers virtuel, `/data`). + Recompilation nulle si rien n'a changé. +- Interface JavaScript (`web/engine-wasm.mjs`) : le module appelle + `engine_init` une fois, puis `main` d'engine-cli pour chaque commande + (`callMain`) ; la sortie est exactement le JSON d'engine-cli, sans code de + mise en JSON dupliqué. Seule modification du C : `main` n'appelle pas + `engine_shutdown` sous Emscripten. Fonctions `bestMove`, `cubeDecision`, + `evaluate`, `reference`. +- Suite de contrat : `tests/test_wasm_reference.sh` (lancé par `make test`) + rejoue les 32 positions à 0 et 2 plis sous Node avec + `engine-wasm/engine-cli.mjs`, qui imite engine-cli ; `compare.py --cli` + accepte désormais une commande avec arguments. Résultat : **2 598 nombres + comparés, écart maximal 9,53e-4** (le même cas `opening-64-match` qu'en + natif), tolérance 2e-3. Comparé directement au natif : mêmes classements, + mêmes décisions, écart maximal 2,3e-6. +- `web/` : page de test (`index.html`), page de mesure (`bench.html`), + Worker en module ES (`engine-worker.mjs`), client (`engine-client.mjs`), + mesure partagée avec Node (`benchmark.mjs`). Aucun framework, aucune étape + de build. +- `make deploy-web` : serveur HTTP de Python dans l'image `build-c` (aucune + nouvelle image), adresse LAN affichée (route par défaut), Ctrl+C l'arrête + (vérifié). `make deploy` renvoie vers `make deploy-web`. `make bench` : + même mesure sous Node. + +Vérification de la page : Chromium 130 et Firefox 131 sans interface (image +`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisée une fois, hors du +dépôt), ouverts sur l'adresse LAN `http://192.168.1.21:8080/` (contexte non +sécurisé, comme le téléphone) : meilleur coup de `middlegame-1` à 2 plis +(`24/14`, 305 ms Chromium, 2 433 ms Firefox), mesure complète, bouton de copie +fonctionnel. Types MIME vérifiés par curl : `application/wasm`, +`text/javascript` pour les `.mjs`. + +Mesures sur le PC (i5-2450M, 2011) : + +| Mesure (max sur les positions) | Node 18 | Chromium 130 | Firefox 131 | Seuil | +| --- | --- | --- | --- | --- | +| Meilleur coup 0 pli | 2 ms | 5 ms | 14 ms | 300 ms | +| Meilleur coup 2 plis (médiane / max) | 107 / 454 ms | 87 / 316 ms | 693 / 2 341 ms | 3 000 ms | +| Videau 2 plis (médiane / max) | 27 / 109 ms | 21 / 103 ms | 158 / 902 ms | 3 000 ms | +| Chargement du moteur | 82 ms | 178 ms | 204 ms | | +| Mémoire WebAssembly | 51,9 Mo | 51,9 Mo | 51,9 Mo | | + +Taille téléchargée : 9,0 Mo non compressée, **7,3 Mo compressée gzip** (seuil +40 Mo), dont `engine.data` 8,7 Mo (7,2 Mo gzip), `engine.wasm` 180 Ko +(78 Ko gzip), `engine.mjs` 73 Ko (20 Ko gzip). + +Points d'attention : Firefox sans interface est environ 7 fois plus lent que +Chromium sur le même PC, à vérifier sur le téléphone (le guide demande les +deux navigateurs). La mémoire de 52 Mo vient surtout des bases de fin de +partie copiées en mémoire et du cache d'évaluation de gnubg ; les données +existent en plus une fois côté JavaScript (système de fichiers virtuel). + +Écarts au plan : aucun sur le fond. Fichiers en `.mjs` (modules ES partagés +entre navigateur et Node sans `package.json`) ; seuils jugés sur le maximum, +la médiane étant donnée en plus ; la taille compressée est calculée par la page +(CompressionStream) car le serveur de test ne compresse pas. diff --git a/tests/reference/README.md b/tests/reference/README.md index 3474fa2..5676d31 100644 --- a/tests/reference/README.md +++ b/tests/reference/README.md @@ -232,8 +232,13 @@ comparateur `compare.py` (Python 3, dans l'image Docker de la chaîne C) lance build/engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS] ``` -qui produit un objet de même structure que `plies[""]` : `evaluation`, -puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la +La même suite est rejouée sur le moteur compilé en WebAssembly +(`tests/test_wasm_reference.sh`), avec le même comparateur et la même +tolérance : `--cli "node engine-wasm/engine-cli.mjs"` interroge, sous Node +dans l'image Emscripten, le même programme compilé en WebAssembly. + +Le programme interrogé produit un objet de même structure que +`plies[""]` : `evaluation`, puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la méthode de `hint` décrite plus haut (`engine_best_moves` dans `engine/engine.c`). Pour voir tous les nombres comparés : `BUILD_C_IMAGE=ustagammon/build-c:2 bash tests/test_engine_reference.sh --details`. From c411771e48e14114d892f46f36329e9fb39fade2 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:23:43 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=C3=89tape=200b=20close=20:=20plan=20suppri?= =?UTF-8?q?m=C3=A9,=20feuille=20de=20route=20et=20journal=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 6 +- docs/journal.md | 29 +++- docs/plans/2026-11-02-moteur-webassembly.md | 157 -------------------- 3 files changed, 25 insertions(+), 167 deletions(-) delete mode 100644 docs/plans/2026-11-02-moteur-webassembly.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index 1f359d6..ca23cbc 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -12,12 +12,12 @@ Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur (ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison -automatique aux références, tolérance 0,002 (0a.4). +automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec +page de test, page de mesure et guide de test (0b). | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0b | Moteur en WebAssembly, page de test dans un Web Worker, `make deploy-web`, `make bench` | 🔄 | `plans/2026-11-02-moteur-webassembly.md` | -| Mesures | Pierre relève les mesures sur le Fairphone 4 | ⏳ | | +| Mesures | Pierre relève les mesures sur le Fairphone 4, en suivant `docs/guide-test-etape-0.md` | 🔄 | | | Décision | Socle PWA ou Android natif, consigné dans un ADR — validation de Pierre | ⏳ | | | 0c | Android natif (NDK, Gradle en ligne de commande, `make deploy` via adb) | ⏸ si 0b échoue | | | 0d | Plateau minimal jouable sur le socle retenu ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | | diff --git a/docs/journal.md b/docs/journal.md index 1705b57..fb871c5 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -38,14 +38,29 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite. - Les hash de commit s'écrivent sans backticks, pour les liens automatiques de Gitea (demande de Pierre). -**Incident ouvert** -- La MR !1 a bien été fusionnée côté git (commit de fusion 6958acc sur `main`), - mais Gitea l'affiche toujours « open ». Cause non établie ; la base de - données du serveur refuse déjà les émojis (erreur MySQL 1366), piste à - vérifier dans les journaux de Gitea. +**Incident (clos)** +- Les MR fusionnées restaient « open » dans Gitea : les hooks git du serveur ne + s'exécutaient plus, sur toute l'instance (diagnostic par un sous-agent, en + lecture seule ; brief transmis à Pierre). Pierre a réparé les hooks et + marqué les MR !1 à !8 comme fusionnées. Les branches fusionnées ont été + supprimées du serveur. + +**Suite de la session (après-midi)** +- ADR 0007 (architecture et conventions de code) et ADR 0008 (glossaire et + scénarios, source unique), puis amendements : scénarios en Gherkin standard + validés par l'analyseur officiel, ADR amendables (0001). +- Étape 0a terminée, par sous-agents : gnubg de référence et 32 positions + (0a.2, MR !6) ; moteur embarqué, `engine.h` et `engine-cli` (0a.3, MR !7) ; + comparaison automatique aux références, tolérance 0,002, deux défauts du + moteur corrigés (0a.4, MR !8). +- Outils de compilation et de test validés par Pierre (amendement de + l'ADR 0004). +- Étape 0b : moteur en WebAssembly, même suite de contrat (écart maximal + 9,5e-4), 7,3 Mo compressés, page de test et de mesure, `make deploy-web`, + guide de test `docs/guide-test-etape-0.md`. **Reste à faire** -- Étape 0a.2 : image Docker de gnubg de bureau compilé depuis le même commit, - et 30 positions de référence ou plus. +- Mesures sur le Fairphone 4 avec Pierre, puis décision du socle (ADR). +- Étape 0d : plateau minimal jouable sur le socle retenu. - `adb` n'est pas installé sur le PC : `sudo apt install adb` avant les tests sur le téléphone. diff --git a/docs/plans/2026-11-02-moteur-webassembly.md b/docs/plans/2026-11-02-moteur-webassembly.md deleted file mode 100644 index a135849..0000000 --- a/docs/plans/2026-11-02-moteur-webassembly.md +++ /dev/null @@ -1,157 +0,0 @@ -# Moteur en WebAssembly : page de test, contrat et mesures - -- **Date** : 2026-11-02 -- **Étape** : 0b -- **Statut** : en cours -- **Exécutant** : sous-agent general-purpose, dans un worktree isolé - -## Objectif - -Le même moteur (`engine/`, `engine.h`) compilé en WebAssembly tourne dans un -Web Worker d'une page web minimale servie sur le réseau local. Il donne les -mêmes résultats que le moteur natif sur les 32 positions de référence, et une -page de mesure produit les chiffres qui permettront de trancher entre PWA et -Android natif (`docs/etape-0.md`, section 0b). - -## Consignes - -1. **Compilation** : `make engine-wasm` compile le moteur avec Emscripten, dans - l'image `emscripten/emsdk:3.1.64` (déjà téléchargée ; version figée), - en C générique (pas de SIMD), sans threads, `-j2`. Sorties dans - `build/wasm/`. Les données (`gnubg.weights` ou `gnubg.wd`, bases `.bd`, - table d'équité) viennent de `build/data/` produit par `make engine`, et sont - chargées une fois (système de fichiers virtuel d'Emscripten ou équivalent - simple). Recompilation minimale si rien n'a changé. Cible visible dans - `make help`. -2. **Interface JavaScript minimale** au-dessus d'`engine.h` (par exemple - `bestMoves`, `cubeDecision`, `evaluate`, `reference`), qui renvoie les mêmes - objets JSON que `engine-cli`. Réutiliser le code de mise en JSON - d'`engine-cli` plutôt que de le réécrire, si c'est simple. -3. **Même suite de contrat** (ADR 0007, point 7) : rejouer les 32 positions de - référence aux niveaux 0 et 2 plis avec le build WebAssembly, sous Node (dans - l'image Emscripten, qui contient Node, ou une image Node figée), avec le - même comparateur `tests/reference/compare.py` et la même tolérance. Le plus - simple : un petit programme Node qui imite l'interface en ligne de commande - d'`engine-cli`, et une option du comparateur pour choisir le programme - interrogé. Test lancé par `make test` (`tests/test_wasm_reference.sh`). -4. **Page de test `web/`** (HTML, CSS et JavaScript sans framework ni étape de - build) : moteur chargé dans un Web Worker, jamais sur le fil d'affichage ; - saisie d'un Position ID, d'un Match ID et des dés ; bouton « Meilleur coup » ; - affichage du résultat et du temps de calcul. Textes en français, identifiants - en anglais. -5. **Page de mesure** (dans `web/`) : sur une série de positions de référence, - temps du meilleur coup à 0 et 2 plis et de la décision de videau à 2 plis - (médiane et maximum), temps de chargement du moteur, taille totale - téléchargée (non compressée et compressée gzip), mémoire WebAssembly - utilisée. Un bouton « Copier les résultats » met un rapport texte dans le - presse-papiers, que Pierre collera dans la conversation. La page compare - elle-même aux seuils de `docs/etape-0.md`. -6. **`make deploy-web`** sert `web/` et `build/wasm/` sur le réseau local - (serveur dans Docker, rien sur le PC ; types MIME corrects dont - `application/wasm`), et affiche l'adresse exacte à ouvrir depuis le - téléphone (IP du PC sur le réseau local). Le serveur s'arrête par Ctrl+C. - Mettre à jour la cible `deploy` si pertinent. -7. **`make bench`** : la même mesure que la page, sous Node, sur le PC, pour un - premier ordre de grandeur (le chiffre qui compte reste celui du téléphone). -8. **Guide de test** pour Pierre : `docs/guide-test-etape-0.md` (document - temporaire de l'étape 0), pas à pas : préparer le PC, lancer - `make deploy-web`, ouvrir l'adresse sur le Fairphone 4 (même Wi-Fi), faire un - essai de meilleur coup, lancer la page de mesure, copier le rapport et le - coller dans la conversation. Phrases courtes, aucune connaissance technique - supposée, que faire si la page ne s'ouvre pas (pare-feu, réseau). - -Contraintes : -- respecter `CLAUDE.md` et les ADR 0004, 0005, 0007 : simplicité, pas de - framework, identifiants en anglais, commentaires et textes en français, tout - outil dans Docker ; -- ne pas modifier `engine/gnubg/` ni les références ; toute adaptation du - moteur passe par `engine/compat/` ou le code d'Ustagammon ; -- signaler chaque nouvelle dépendance et sa licence dans `CREDITS.md` - (Emscripten : MIT/NCSA) ; -- `make lint` et `make test` doivent passer ; commits petits, en français, avec - la ligne `Co-Authored-By: Claude Opus 5.5 ` ; -- ne pas pousser ni créer de merge request ; -- ne rien simuler : les mesures viennent d'exécutions réelles. - -Rapport attendu : ce qui a été fait, commandes de vérification, taille des -fichiers téléchargés, mesures `make bench` sur le PC, résultat de la suite de -contrat en WebAssembly (écart maximal), difficultés, écarts au plan. - -## Critères de réussite - -- `make engine-wasm` puis `make test` : les 32 positions concordent en - WebAssembly avec la même tolérance qu'en natif. -- `make deploy-web` affiche une adresse ; la page donne le meilleur coup d'une - position de référence avec son temps de calcul (vérifié dans un navigateur - sans interface si possible, sinon par une requête HTTP sur les fichiers). -- `make bench` produit un rapport. -- `make lint` et `make test` passent. - -## Résultat - -**Statut : terminé** (2026-11-02, branche `wasm-travail`). Tous les critères -de réussite sont tenus ; reste la mesure sur le Fairphone 4 par Pierre -(`docs/guide-test-etape-0.md`). - -Ce qui a été fait : - -- `make engine-wasm` (`engine-wasm/wasm.mk`) compile dans - `emscripten/emsdk:3.1.64` les mêmes sources que le natif (liste commune - `engine/sources.mk`), plus `engine-cli/main.c`, en C générique, sans - threads, `-O3 -ffast-math`. Les données de `build/data/` sont embarquées - dans `engine.data` (`--preload-file`, système de fichiers virtuel, `/data`). - Recompilation nulle si rien n'a changé. -- Interface JavaScript (`web/engine-wasm.mjs`) : le module appelle - `engine_init` une fois, puis `main` d'engine-cli pour chaque commande - (`callMain`) ; la sortie est exactement le JSON d'engine-cli, sans code de - mise en JSON dupliqué. Seule modification du C : `main` n'appelle pas - `engine_shutdown` sous Emscripten. Fonctions `bestMove`, `cubeDecision`, - `evaluate`, `reference`. -- Suite de contrat : `tests/test_wasm_reference.sh` (lancé par `make test`) - rejoue les 32 positions à 0 et 2 plis sous Node avec - `engine-wasm/engine-cli.mjs`, qui imite engine-cli ; `compare.py --cli` - accepte désormais une commande avec arguments. Résultat : **2 598 nombres - comparés, écart maximal 9,53e-4** (le même cas `opening-64-match` qu'en - natif), tolérance 2e-3. Comparé directement au natif : mêmes classements, - mêmes décisions, écart maximal 2,3e-6. -- `web/` : page de test (`index.html`), page de mesure (`bench.html`), - Worker en module ES (`engine-worker.mjs`), client (`engine-client.mjs`), - mesure partagée avec Node (`benchmark.mjs`). Aucun framework, aucune étape - de build. -- `make deploy-web` : serveur HTTP de Python dans l'image `build-c` (aucune - nouvelle image), adresse LAN affichée (route par défaut), Ctrl+C l'arrête - (vérifié). `make deploy` renvoie vers `make deploy-web`. `make bench` : - même mesure sous Node. - -Vérification de la page : Chromium 130 et Firefox 131 sans interface (image -`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisée une fois, hors du -dépôt), ouverts sur l'adresse LAN `http://192.168.1.21:8080/` (contexte non -sécurisé, comme le téléphone) : meilleur coup de `middlegame-1` à 2 plis -(`24/14`, 305 ms Chromium, 2 433 ms Firefox), mesure complète, bouton de copie -fonctionnel. Types MIME vérifiés par curl : `application/wasm`, -`text/javascript` pour les `.mjs`. - -Mesures sur le PC (i5-2450M, 2011) : - -| Mesure (max sur les positions) | Node 18 | Chromium 130 | Firefox 131 | Seuil | -| --- | --- | --- | --- | --- | -| Meilleur coup 0 pli | 2 ms | 5 ms | 14 ms | 300 ms | -| Meilleur coup 2 plis (médiane / max) | 107 / 454 ms | 87 / 316 ms | 693 / 2 341 ms | 3 000 ms | -| Videau 2 plis (médiane / max) | 27 / 109 ms | 21 / 103 ms | 158 / 902 ms | 3 000 ms | -| Chargement du moteur | 82 ms | 178 ms | 204 ms | | -| Mémoire WebAssembly | 51,9 Mo | 51,9 Mo | 51,9 Mo | | - -Taille téléchargée : 9,0 Mo non compressée, **7,3 Mo compressée gzip** (seuil -40 Mo), dont `engine.data` 8,7 Mo (7,2 Mo gzip), `engine.wasm` 180 Ko -(78 Ko gzip), `engine.mjs` 73 Ko (20 Ko gzip). - -Points d'attention : Firefox sans interface est environ 7 fois plus lent que -Chromium sur le même PC, à vérifier sur le téléphone (le guide demande les -deux navigateurs). La mémoire de 52 Mo vient surtout des bases de fin de -partie copiées en mémoire et du cache d'évaluation de gnubg ; les données -existent en plus une fois côté JavaScript (système de fichiers virtuel). - -Écarts au plan : aucun sur le fond. Fichiers en `.mjs` (modules ES partagés -entre navigateur et Node sans `package.json`) ; seuils jugés sur le maximum, -la médiane étant donnée en plus ; la taille compressée est calculée par la page -(CompressionStream) car le serveur de test ne compresse pas.