PS2. Bibliothèque Compatibilité Technique À propos

Trois bugs d'émulation, trouvés à la mesure

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.


00 — Un jeu qui ne démarre pas

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.

Symptômes relevés — Play! 0.72 natif
ÉlémentÉtat
Boot, chargement de l'exécutableOK
Audio (musique, vidéos)OK
Menu : texte et éléments 2DOK
Menu : fond vidéoabsent
En jeu : HUDOK
En jeu : monde 3Dtotalement absent
Appels de dessin par image3 à 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.

01 — Méthode

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é.

02 — Premier bug : verrou vivant à la lecture du disque

Cause racine confirmée · corrigée

Deux consommateurs du lecteur se bloquent mutuellement

La preuve

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)

Le mécanisme

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.

Le correctif

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.

Mesures avant / après — Source/iop/Iop_Cdvdman.cpp
IndicateurAvantAprès
Erreurs de lecture CD23854
Avertissements « pending »238917
Sortie du jeu110 Ko227 Ko
Appels de dessin par image, en jeu3 à 20250 à 687
Monde 3Dabsentrendu 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.

03 — Deuxième bug : 32 octets qui gèlent le cache de textures

Cause racine confirmée · corrigée

Un transfert sous-alimenté n'invalide jamais la texture

La preuve

Transfer abandoned: 32 of 524288 bytes never arrived (99% delivered).  ×1723
3385 transferts démarrés vers 0x001E0000, 2 achevés.

Le mécanisme

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.

Le correctif

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é.

04 — Troisième bug : tressaillement en rendu par trame

Cause racine confirmée · corrigée

Une demi-ligne de décalage, présentée plein écran à 50 Hz

Le relevé

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 :

Registres relevés — menu principal, 1947 images
RegistreValeur
SMODE2entrelacé, mode trame
DISPLAY2constant, 640 × 256 après repli
DISPFB2alterne entre deux tampons
XYOFFSET.OFYalterne 1920,00 ↔ 1920,50

Le mécanisme

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.

La vérification

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 :

Décalage vertical mesuré entre images consécutives
RéglageDécalage moyenÉnergie alternéeÉcart-type
sans correctif0,44 px0,37 px0,71 px
avec correctif0,04 px0,00 px0,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.

05 — Le portage web : trois défauts de synchronisation

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.

Attente active à chaque lecture de secteur

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.

Le limiteur d'images dérivait

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.

Décalage croissant entre l'image et le son

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.

Les compteurs de charge processeur restaient à zéro

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.

06 — Hypothèses réfutées

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.

Réfutée

« La vidéo est rejetée par le test de profondeur »

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.

Réfutée

« Le décodeur MPEG2 produit une image délavée »

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.

Réfutée

« Une division par zéro corrompt la géométrie »

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.

07 — Limites et réserves

Ce qui reste à vérifier

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é.

Constats non corrigés

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.

Remontée en amont

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.

Ce que l'utilisateur constate

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.