J'ai regardé les trois fichiers (`tournoi.log`, `tournoi.pgn`, et les sources C de `zct.zip`). Voici ce qui s'est passé.

## Le déroulé

Après **20. Nxd7** (le cavalier blanc prend en d7), c'est au tour de ZCT (Noirs) de répondre. Le moteur lance sa recherche normalement, jusqu'à ce que ça dérape à la profondeur 12 :

```
24016 ... 12 +1113 37 308304  20. g6 21. Nxf6+ ...
24078 ... 12 +32765 43 366977 20. Rb7#
24081 ... 12 +32765 43 366987 18. ILL(B2B7,0)
24081 ... 13 +32765 43 366988 17... ILL(B2B7,0)
24081 ... 13 +32765 43 367019 2.  ILL(B2B7,0)
24081 ... 14 +32765 43 367026 1... ILL(B2B7,0)
24081 ... 14 +32765 43 367057 1.  ILL(B2B7,0)
Terminating process of engine ZCT 0.3.2498(1)
```

Deux anomalies apparaissent en même temps :
1. Le **score explose à +32765** — c'est quasiment la valeur maximale d'un entier 16 bits (32767), typique d'un score "mat" qui n'a pas été correctement borné.
2. Le coup annoncé passe de variations normales à **`Rb7#`**, un coup impossible dans cette position, avec un numérotage de coup qui n'a plus de sens (`20.` au lieu de `20...`, puis `18.`, `2.`, `1...`, `1.` — la numérotation elle-même part en vrille).

## La cause : un bug interne connu du moteur

En regardant le code source, le message `ILL(B2B7,0)` vient de `output.c`, dans la fonction qui convertit un coup en notation SAN. Le commentaire des développeurs est explicite :

```c
/* This shouldn't ever happen! But it does... */
if (!make_move(move))
{
    sprint(str, sizeof(str), "ILL(%S%S,%i)", MOVE_FROM(move),
        MOVE_TO(move), MOVE_SCORE(move));
```

Autrement dit : la recherche a sélectionné comme « meilleur coup » un coup (tour de b2 vers b7) qui s'avère **illégal une fois rejoué sur le vrai plateau**. Le moteur détecte l'incohérence lui-même, mais au lieu de se rattraper, il boucle : il relance des « itérations » (profondeur 12 → 13 → 14) qui n'avancent quasiment pas en nœuds (366 977 → 367 057, soit presque rien) et retombent toujours sur le même coup illégal.

Le `logfile.txt` fourni dans l'archive du moteur montre que **ce n'est pas un cas isolé** : on y trouve exactement le même symptôme (score à ±32764/32765 et boucle de `ILL(G6G5,0)` répétée) lors de tests précédents — c'est donc un bug latent connu du moteur, pas un accident de cette seule partie.

## Pourquoi ça pointe vers le SMP

ZCT tourne ici en **4 threads** (`Using 4 processors`, log ligne 181), et son `CHANGES.txt` liste des années d'instabilité autour du SMP/DTS ("*SMP bugfixes, bugs introduced*", "*Fix SMP completely, fix hangs/scaling on more than 2 processors*"...). Le tableau classique est celui-ci :
- une entrée de table de hachage corrompue par une **collision** ou une **écriture concurrente non synchronisée** entre threads,
- qui associe une position à un score de mat aberrant (d'où le +32765),
- et à un coup qui n'appartient pas réellement à cette position (d'où le B2-B7 illégal).

## Pourquoi la déconnexion

Le moteur n'a jamais réussi à produire de commande `move` valide après `Nxd7` — il est resté bloqué dans cette boucle d'erreurs. C'est l'arbitre du tournoi (le gestionnaire de match) qui a fini par tuer le processus (`Terminating process of engine ZCT 0.3.2498(1)`), d'où le `1-0 {Black disconnects}` dans le PGN : ce n'est pas une vraie déconnexion réseau, mais un **crash logique en boucle** suite à un coup illégal généré par un bug de recherche/hachage (probablement lié au SMP à 4 threads).

--------------------------------------------------------------------------------

Oui — il y a une solution rapide pour le tournoi, et un vrai correctif à apporter au code. Les deux sont utiles, car même corrigé, le code doit aussi savoir "s'auto-protéger" si le problème resurgit.

## 1. Contournement immédiat (pour ne pas perdre d'autres parties)

Le déclencheur le plus probable est le mode multi-thread (`Using 4 processors`). Le plus sûr avant la prochaine partie :

- Relancer ZCT avec **1 seul processeur** (`cores`/`Threads` = 1, ou binaire compilé sans `-DSMP`).
- Vérifier `ZCT.ini` / options UCI pour forcer `Threads=1`.

Ça n'élimine pas le bug en soi, mais ça supprime le facteur qui le déclenche (l'accès concurrent au hash), donc les parties suivantes du tournoi ne devraient plus planter de cette façon.

## 2. La cause précise, au niveau du code

Dans `hash.c`, l'écriture d'une entrée se fait ainsi (`hash_store`) :

```c
entry->entry[best_slot].data = data;
entry->entry[best_slot].hashkey = hashkey;   // hashkey = board.hashkey ^ data
```

Et la lecture (`hash_probe`) fait l'inverse :

```c
hashkey = entry->entry[x].hashkey;
data = entry->entry[x].data;
if ((hashkey ^ data) == board.hashkey)   // "preuve" que l'entrée correspond bien à la position
```

C'est le classique **« lockless hashing » par XOR** (utilisé par Crafty, entre autres) : en théorie, si les deux champs sont incohérents (écriture interrompue par un autre thread), le XOR ne retombera pas sur `board.hashkey` et l'entrée sera rejetée.

Le problème : ces deux champs (`data`, `hashkey`) sont de simples `BITBOARD`, **sans `volatile`, sans atomiques, sans barrière mémoire**, alors qu'ils sont partagés entre 4 threads sans aucun verrou (`smp.c` a des locks pour les split points, mais aucun pour la table de hachage). Rien n'empêche :
- le compilateur de réordonner les deux écritures/lectures à l'optimisation,
- ou un thread de lire l'entrée pile pendant qu'un autre thread est en train de l'écrire pour une **position totalement différente**.

C'est un cas classique de "data race" au sens du C : comportement indéfini, qui ne se manifeste que rarement — exactement le profil d'un bug documenté dans les `CHANGES.txt` de ZCT depuis 2008 ("*SMP bugfixes, bugs introduced*", "*Fix SMP completely*" jamais cochée) et déjà visible dans le `logfile.txt` fourni avec le moteur.

## 3. Correctif proposé

**a) Sécuriser l'accès au hash (la vraie cause)**

Le plus simple sans réécrire toute l'architecture : rendre les deux champs `_Atomic` (ou `volatile` + intrinsèques de barrière) et forcer l'ordre garanti côté écriture et lecture :

```c
// hash.h — dans la déclaration de HASH_ENTRY
struct
{
    _Atomic BITBOARD hashkey;
    _Atomic BITBOARD data;
} entry[HASH_SLOT_COUNT];
```

```c
// hash_store() — écriture avec ordre garanti
atomic_store_explicit(&entry->entry[best_slot].data, data, memory_order_relaxed);
atomic_store_explicit(&entry->entry[best_slot].hashkey, hashkey, memory_order_release);
```

```c
// hash_probe() — lecture avec ordre garanti
hashkey = atomic_load_explicit(&entry->entry[x].hashkey, memory_order_acquire);
data    = atomic_load_explicit(&entry->entry[x].data, memory_order_relaxed);
```

Ça coûte quasiment rien en performance (une barrière release/acquire, pas un verrou global) et supprime la course.

**b) Un filet de sécurité applicatif, indépendamment de (a)**

Le code sait déjà se méfier du hash à un endroit (`pv_string()`, ligne ~460) :

```c
if (sb.hash_move == NO_MOVE || !move_is_valid(sb.hash_move) || !is_legal(sb.hash_move))
    break;
```

Mais ce même filet **n'existe pas** au moment où le coup final est envoyé à l'arbitre (`move_string()`, celle qui produit `ILL(...)`). Il faudrait, dans `searchroot.c`, juste avant d'envoyer le `bestmove` :

```c
if (!move_is_valid(zct->best_move) || !is_legal(zct->best_move))
{
    print("Warning: corrupted best move detected, falling back.\n");
    zct->best_move = first_legal_root_move();   // ou le meilleur coup du dernier itération valide
}
```

Ainsi, même si une corruption résiduelle se produit un jour, le moteur **envoie toujours un coup légal** au lieu de boucler indéfiniment sur `ILL(...)` jusqu'à ce que le gestionnaire de tournoi le tue.

## 4. Côté gestionnaire de tournoi

En complément, configurer un watchdog plus réactif (arrêt/forfait après N répétitions du même message d'erreur, plutôt que d'attendre le time-out) éviterait qu'une partie reste bloquée aussi longtemps avant d'être arbitrée.

--------------------------------------------------------------------------------

Bon, j'ai localisé précisément le point d'injection du bug dans `zct.c` (là où `board.pv_stack[0][0]` est imprimé sans jamais être revérifié). Je vais maintenant appliquer les deux correctifs sur une copie du code source, puis repackager le projet complet.Correctif 1 — `hash.c` : suppression de la course de données dans la table de hachage partagée (SMP).That's just a debug display command (`hash_print`, single-threaded on demand), not on the hot search path — I'll leave it as-is and focus the fix on the two functions that actually matter during concurrent search: `hash_probe` (line 55) and `hash_store` (write side).Correctif 2 — filet de sécurité applicatif : ne jamais envoyer un coup non revérifié à l'arbitre.Je vais ajouter la fonction de repli à la fin de `searchroot.c`, la déclarer dans `functions.h`, puis l'appeler dans `zct.c` juste avant l'envoi du coup.Maintenant le point d'appel dans `zct.c`, juste avant que le coup ne soit imprimé/joué :I dois corriger le `make_move()` final pour qu'il ne soit pas appelé si le repli échoue aussi (partie sans coup légal = fin de partie, cas déjà géré ailleurs normalement, mais on sécurise quand même) :Ajoutons une entrée au changelog, puis compilons pour valider :La compilation réussit sans aucun warning ni erreur sur les fichiers modifiés. Testons rapidement que le moteur répond correctement au protocole :Le moteur corrigé compile et tourne normalement en SMP (4 threads), avec une recherche saine. Je prépare maintenant le projet complet à télécharger, avec un résumé des modifications.Maintenant je crée un fichier de notes de correctifs et je package le tout dans un zip prêt à l'emploi.Voilà le projet ZCT complet avec les correctifs appliqués, compilé et testé.

**Ce qui a changé** (détail dans `PATCH_NOTES_FR.md` à la racine de l'archive) :

- **`hash.c`** — la vraie cause : les lectures/écritures de la table de hachage partagée entre les 4 threads de recherche se faisaient sans aucune synchronisation, ce qui pouvait produire une entrée « déchirée » (un score/coup mélangeant deux positions différentes). Corrigé avec des opérations atomiques (`__atomic_load_n`/`__atomic_store_n`) et un ordre d'écriture/lecture garanti.
- **`zct.c` + `searchroot.c` + `functions.h`** — filet de sécurité : le coup final est maintenant revérifié (légalité) juste avant d'être envoyé à l'arbitre ; en cas de problème, une nouvelle fonction `fallback_legal_root_move()` renvoie un coup garanti légal au lieu de boucler sur `ILL(...)` jusqu'au timeout.
- **`CHANGES.txt`** — entrée de changelog documentant le correctif (version 0.3.2499).

**Validation faite** : recompilation propre avec `make CFLAGS="-DSMP -DMAX_CPUS=4 -Wall"` (aucune erreur, aucun nouveau warning), et test d'exécution en 4 threads confirmant une recherche saine.

Pour recompiler chez vous :
```
cd zct
make CFLAGS="-DSMP -DMAX_CPUS=4"
```
