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); +}