Journal technique d'un portage. Aucun des trois défauts n'a été trouvé par lecture de code : chacun l'a été en instrumentant l'émulateur et en comparant des relevés. Deux fausses causes racines ont été annoncées puis réfutées en chemin — elles figurent ici aussi.
Le point de départ tient en une phrase : Aggressive Inline (PAL, SLES-504.80) affiche son menu, joue sa musique, puis rien. En jeu, le HUD est correct et le monde 3D totalement absent. Le personnage tombe en boucle et réapparaît.
La base retenue est Play!, seul émulateur PS2 disposant d'une cible WebAssembly fonctionnelle. Un portage de PCSX2 a été écarté d'emblée : ses recompilateurs x86-64 n'ont pas d'équivalent WASM.
| Élément | État |
|---|---|
| Boot, chargement de l'exécutable | OK |
| Audio (musique, vidéos) | OK |
| Menu : texte et éléments 2D | OK |
| Menu : fond vidéo | absent |
| En jeu : HUD | OK |
| En jeu : monde 3D | totalement absent |
| Appels de dessin par image | 3 à l'écran blanc, 19 avec HUD |
Le registre de compatibilité officiel classait ce titre issue-fmacstall
depuis 2018 — « géométrie qui explose ». Le symptôme observé est
l'inverse : zéro géométrie, pas de la géométrie fausse. Ce label a été
écarté d'emblée, et il s'est avéré qu'il n'avait effectivement rien à voir.
Trois outils, dans cet ordre : un frame dump décodé par des scripts Python, un build instrumenté avec journalisation activée, et la sortie standard du jeu lui-même. C'est le troisième qui a tranché — et il était disponible depuis le début.
La leçon de méthode. J'ai analysé 184 paquets GS, décodé
des états de VU1, tracé des divisions par zéro — pour découvrir ensuite que
le jeu écrivait Failed to read from CD media dans un fichier
texte, 2385 fois. Chercher d'abord ce que le programme dit de lui-même
aurait fait gagner l'essentiel du temps passé.
Le build instrumenté produit un avertissement par erreur du jeu. La correspondance est exacte :
2389 × "Trying to start a read while another command is pending." (Play!) 2385 × "CDDVD : ERROR, Failed to read from CD media" (le jeu)
La trace complète révèle deux consommateurs simultanés du lecteur, reconnaissables à leurs pointeurs de mode distincts : le streaming audio, en lecture anticipée par blocs de 16 secteurs, et le chargeur de fichiers, par blocs de 32 secteurs. L'instant de rupture :
CdRead(startSector=0x65A, sectorCount=0x20, modePtr=0x0005AF78); CdSync(mode = 0); CdRead(startSector=0x10, sectorCount=0x1, modePtr=0x000635B0); Trying to start a read while another command is pending.
CdRead renvoyait 0 — un échec — dès qu'une
commande était en cours. Une lecture de 32 secteurs occupe 16,1 ms de
fenêtre simulée, pendant laquelle l'autre consommateur se voit refuser la
sienne. Chacun réessaie, chacun retombe sur l'autre :
verrou vivant. Rien ne se charge jamais, et de là découle
tout le tableau — pas de géométrie, pas de vidéo, personnage qui tombe sans
fin. Ce comportement avait été introduit pour un autre titre.
Les secteurs sont copiés de façon synchrone ; l'état « en attente » ne modélise que du temps. Au lieu de refuser la lecture, on termine immédiatement la commande en cours et on accepte la nouvelle.
| Indicateur | Avant | Après |
|---|---|---|
| Erreurs de lecture CD | 2385 | 4 |
| Avertissements « pending » | 2389 | 17 |
| Sortie du jeu | 110 Ko | 227 Ko |
| Appels de dessin par image, en jeu | 3 à 20 | 250 à 687 |
| Monde 3D | absent | rendu complet |
Portée. Le correctif est dans du code de cœur commun à tous les backends : il vaut identiquement pour la cible WebAssembly, où le symptôme était rigoureusement le même. Il concerne tout titre faisant des accès disque concurrents.
Transfer abandoned: 32 of 524288 bytes never arrived (99% delivered). ×1723 3385 transferts démarrés vers 0x001E0000, 2 achevés.
Le jeu programme un transfert de 512×256 pixels, soit 524 288 octets, puis envoie les pixels dans une seule étiquette annonçant 32 766 quadwords : 524 256 octets. Il en manque 32, à chaque image de chaque vidéo.
Sur console c'est sans conséquence : le processeur graphique écrit les pixels au fil de l'eau et le transfert suivant repart de zéro. Dans Play!, la fonction qui notifie le moteur de rendu n'est appelée que lorsque le compteur d'octets restants tombe pile à zéro. Comme il s'arrête à 32, l'appel n'a jamais lieu — or c'est lui qui invalide le cache de textures. La texture reste figée sur le contenu qu'elle avait à sa création : une allocation non initialisée, d'où l'aplat clair mesuré à l'écran.
Les pixels, eux, arrivaient bien : extraits de la mémoire graphique, ils donnaient une image parfaitement nette et différente à chaque fois. Le défaut était strictement dans la notification.
Ce qui a permis de trancher. Une manipulation de l'utilisateur : sauvegarder puis recharger fait apparaître une image correcte, qui se fige — et recharger la même sauvegarde redonne la même image. Le chargement d'état invalide tout le cache : la texture est réuploadée une fois, puis plus jamais. Un problème de profondeur aurait donné une image différente à chaque rechargement. C'est cette observation qui a distingué cette cause de toutes les autres.
On vide le transfert en cours dès que le jeu reprogramme la zone de transfert, tant que les anciens registres sont encore intacts. L'appel est placé avant l'affectation du registre — c'est ce qui garantit qu'ils décrivent encore le transfert qu'on vide.
Portée. Ni spécifique à ce jeu ni au backend OpenGL : tout titre qui sous-alimente un transfert d'image voit son cache de textures gelé.
Symptôme signalé : « l'écran tressaille, comme sur les vieilles télés ». Journal posé au seul point qui reflète ce que la présentation utilise réellement, sur 1947 images consécutives :
| Registre | Valeur |
|---|---|
| SMODE2 | entrelacé, mode trame |
| DISPLAY2 | constant, 640 × 256 après repli |
| DISPFB2 | alterne entre deux tampons |
| XYOFFSET.OFY | alterne 1920,00 ↔ 1920,50 |
C'est du rendu par trame entrelacée canonique. Le jeu ne dessine qu'une trame sur deux, dans deux tampons de demi-hauteur échangés à chaque trame. Pour que les lignes s'intercalent, il décale sa géométrie d'une demi-ligne — d'où l'offset qui alterne.
Sur un téléviseur, les deux trames s'entrelacent : image stable de 512 lignes. Dans Play!, chaque tampon de 256 lignes est présenté seul, plein écran, à 50 Hz. Le décalage d'une demi-ligne devient une oscillation verticale de toute l'image, cinquante fois par seconde.
40 captures consécutives, décalage vertical estimé au dixième de pixel par corrélation des profils de luminance, zone d'analyse limitée à l'interface pour écarter l'animation du fond :
| Réglage | Décalage moyen | Énergie alternée | Écart-type |
|---|---|---|---|
| sans correctif | 0,44 px | 0,37 px | 0,71 px |
| avec correctif | 0,04 px | 0,00 px | 0,05 px |
Le correctif ramène l'offset vertical sur la ligne entière quand le mode entrelacé par trame est actif, ce qui aligne les deux trames. Il est désactivable par une préférence. Contre-épreuve faite sur la version release, en jeu : énergie alternée 0,01 px.
Nuance assumée. C'est un changement de politique de rendu plus qu'une correction de bug. Un vrai désentrelacement par tissage restituerait la résolution verticale complète — mais c'est une fonctionnalité, pas un correctif.
Les trois bugs précédents étaient communs au natif et au navigateur. Trois autres ne se manifestaient que dans l'onglet, et chacun a été identifié à la mesure, pas au diagnostic.
Le fil d'émulation interrogeait le fil principal en boucle jusqu'à ce que la lecture aboutisse. Chaque tour coûtait une image d'affichage complète : sur 86 secondes de session, 77 étaient passées à attendre. Remplacé par un réveil direct en mémoire partagée — l'attente est tombée à 1,7 s.
Il raisonnait en durées de sommeil, si bien que chaque imprécision s'ajoutait à la suivante. L'émulation tournait à 54 images/s pour une cible de 50. Il vise désormais des échéances absolues.
Conséquence du précédent : le processeur sonore produit ses échantillons au rythme des cycles émulés, donc 8 % d'images en trop donnaient 80 ms de son excédentaire par seconde. Le retard atteignait plusieurs secondes. La cadence corrigée, il a disparu.
En ajoutant l'affichage des statistiques, la charge de l'Emotion Engine et celle de l'IOP rendaient obstinément zéro. Le gestionnaire de statistiques est alimenté par deux signaux distincts : celui du synthétiseur graphique, qui compte images et appels de dessin, et celui de la machine virtuelle, qui porte les temps processeur. Le portage web ne connectait que le premier — l'interface de bureau connecte bien les deux. Une ligne manquante, sans le moindre message d'erreur : la fonction répondait, elle répondait zéro.
Deux fausses causes racines ont été annoncées avec assurance avant d'être démolies par un relevé plus complet. Elles figurent ici parce qu'elles font partie du travail — et parce que ce qui les a réfutées est instructif.
L'analyse tenait : huit couches plein écran écrivent la profondeur
jusqu'à 401, la vidéo arrive avec une profondeur de 5, et le test
GEQUAL la rejette. Elle reposait sur un relevé
incomplet : je n'avais lu les registres de test que
pour le dessin vidéo.
En relevant l'état de chaque dessin plein écran, deux faits la démolissent. Le sprite d'effacement remet bien le tampon de profondeur à zéro à chaque image. Et les huit couches portent l'idiome PS2 « n'écris que la couleur », que Play! traite déjà en désactivant l'écriture de profondeur. Le tampon vaut donc 0 quand la vidéo arrive : elle n'a jamais été rejetée.
Le symptôme qui la contredisait était déjà là : après rechargement d'une sauvegarde, l'image affichée est la même à chaque fois. Un rejet par profondeur aurait laissé passer l'image courante de la vidéo, donc une image différente à chaque essai. Je l'avais sous les yeux sans le lire.
Les statistiques du contenu décodé semblaient accablantes : tout tassé dans les valeurs claires, moyenne 180, écart-type 30 — une bouillie quasi blanche. La conclusion « l'IPU décode mal » paraissait inévitable.
Elle venait d'une erreur de lecture de la mémoire graphique. Celle-ci est organisée en pages, blocs et colonnes entrelacés ; une lecture linéaire donne une bouillie. En la désentrelaçant correctement, l'image extraite est parfaitement nette : un skateur en maillot jaune en plein saut dans un skatepark couvert, public et charpente compris. Le décodeur était irréprochable depuis le début.
Celle-ci était étayée : 1012 occurrences de FLT_MAX, toutes
dans la même composante, le drapeau matériel de division par zéro levé, et
jusqu'à l'instruction fautive identifiée — une division de perspective par
zéro. Tout était vrai.
Mais c'était une conséquence, pas la cause. Le jeu ne pouvant pas lire son disque, il calculait sur des données jamais chargées. Corriger le verrou CDVD a fait disparaître la corruption. Une chaîne causale correcte peut être entièrement juste et pourtant partir du mauvais bout.
Ce que ces trois cas ont en commun. Chaque fois, le relevé était partiel et l'hypothèse cohérente avec ce qu'il montrait. Ce n'est jamais un raisonnement fautif qui a induit en erreur, c'est un relevé qu'on croit complet.
Le vidage des transferts partiels s'exécute des milliers de fois par minute, y compris sur des destinations jamais examinées. Le garde-fou le rend inoffensif en théorie, et intro, menu, HUD et textures de niveau sont corrects — mais une session de jeu prolongée reste à faire pour écarter toute régression.
La mesure du tressaillement a été faite caméra immobile. Le comportement caméra en mouvement n'a pas été mesuré.
Deux incohérences réelles de Play!, repérées en chemin et laissées en l'état : la conversion flottant→entier du backend x86 est la seule des quatre à ne pas saturer comme le fait le matériel, et un chemin de transfert interne à la mémoire graphique n'invalide pas le cache de textures. Ni l'une ni l'autre n'est la cause des bugs traités ici.
Les deux premiers correctifs ne sont pas spécifiques à ce jeu et méritent d'être proposés au projet d'origine. Trois obstacles avant toute soumission : les commentaires sont en français, la copie de travail n'est pas un dépôt git, et les modifications de diagnostic sont mêlées aux correctifs. Il faut forker, cloner à neuf, et rejouer chaque correctif en un commit propre et anglophone.
Firefox est jouable mais ralenti, et certains niveaux y saturent la mémoire au chargement : le compilateur à la volée crée un module WebAssembly par bloc de code traduit, sans jamais les recycler. La cause est identifiée, la corriger demande de toucher au cœur du moteur — ce qui n'a pas été fait. Le décodage vidéo reste par ailleurs nettement plus lent qu'en natif.