Moteur en WebAssembly : make engine-wasm et suite de contrat
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
850de27e90
commit
b0030521d8
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
+2
-5
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user