ARCHITECTURE
La NES dans le navigateur : pas 60 images, 60,0988
Le matériel NES produit 60,0988 images par seconde ; un navigateur dessine au rythme de l'écran. Les décisions prises entre ces deux horloges en construisant Kaset : garder le cœur interchangeable, sortir le rendu du thread principal, et traiter le signal composite comme le support pour lequel les jeux ont été conçus.
Le premier nombre que l'on rencontre en construisant un lecteur NES pour le navigateur n'est pas rond. La console ne produit pas 60 images par seconde ; la NES NTSC tourne à 60,0988 Hz. L'écran, lui, dessine à son propre rythme.
Kaset est un lecteur NES qui tourne dans le navigateur : vous déposez un fichier .nes sur la fenêtre, le jeu s'exécute sur votre appareil, et le fichier ne part jamais vers un serveur. Cette note consigne les décisions prises en le construisant — garder le cœur interchangeable, sortir le rendu du thread principal, et traiter la vidéo composite non comme une option stylistique mais comme le support pour lequel les jeux ont été conçus. Toute la chaîne commence par ces quatre chiffres après la virgule.
Un nombre : 60,0988
L'écart est facile à rendre concret. À 0,0988 image par seconde, cela fait environ 356 images par heure, un rapport d'environ 0,165 %. Autrement dit, toutes les dix secondes environ, la NES produit une image de plus que ce qu'un affichage à 60 Hz peut montrer.
Ce surplus doit être inscrit quelque part. Si l'on jette l'image, le mouvement se fige un instant ; si on l'accumule du côté audio, l'image et le son se désynchronisent. Chaque décision ci-dessous tourne autour de la même question : où la différence est-elle enregistrée.
D'où vient l'horloge
Le nombre n'a pas été choisi ; il tombe d'une division. En NTSC, l'horloge maîtresse doit valoir six fois la sous-porteuse de couleur, et cette exigence produit deux chiffres bizarres : l'horloge maîtresse vaut par définition 236,25 MHz divisés par onze, soit 21,477272 MHz. Le PPU consomme quatre de ces battements par point et le CPU douze — c'est pourquoi exactement trois points PPU tiennent dans un cycle CPU en NTSC.
master 21.477272 MHz (236.25 / 11)
dot 21.477272 / 4 = 5.369318 MHz
CPU 21.477272 / 12 = 1.789773 MHz
frame 341 dots x 262 lines = 89,342 ticks La géométrie de l'image n'est pas non plus un choix de conception ; elle sort directement du signal. Sur les 262 lignes, 240 sont visibles, une est une ligne post-rendu, vingt sont du retour vertical et une est pré-rendu. En PAL, la même chaîne part d'une horloge maîtresse à 26,6017125 MHz à travers 312 lignes, et là 3,2 points PPU tiennent dans un cycle CPU.
Un point de moins sur les images impaires
Le détail qui fixe les dernières décimales est ici. Avec le rendu activé, chaque image PPU de rang impair est plus courte d'un battement d'horloge PPU ; le saut se fait en passant directement du point (339,261) au point (0,0), si bien que le point qui disparaît est (340,261). Avec le rendu désactivé, il n'y a aucun saut et chaque image effectue les 89 342 battements complets.
L'arithmétique suit. Quand les images alternent entre 89 342 et 89 341 battements, la moyenne est 89 341,5, et diviser la cadence des points par cette moyenne donne 60,09881 Hz ; sans le saut, on obtiendrait 60,09848. NESdev publie une seule valeur dans sa table : 60,0988. Ce qui sépare ces dernières décimales, c'est un battement d'horloge abandonné une image sur deux.
Côté Kaset, la cadence des images est mesurée : une valeur effectiveFps sur l'objet frameTiming est évaluée par rapport à 60, avec deux seuils distincts pour l'écart. Les 0,0988 propres à la console se situent bien en dessous des deux — la mesure porte l'écart du matériel sans le traiter comme du bruit.
Quelle horloge donne le tempo
Le navigateur offre deux sources de temps, et aucune n'est celle de la console. Côté affichage, requestAnimationFrame exécute la fonction de rappel avant le prochain repaint ; la cadence d'appel correspond en général au taux de rafraîchissement de l'écran, et dans les onglets en arrière-plan la plupart des navigateurs la suspendent. Côté audio, la spécification Web Audio est explicite : le temps écoulé sur currentTime appartient au flux audio et peut ne pas être synchronisé avec les autres horloges du système.
L'horloge audio change aussi d'un appareil à l'autre. Lorsqu'aucune option n'est donnée, la fréquence d'échantillonnage est celle que préfère le périphérique de sortie — typiquement entre 8 000 et 96 000 Hz, le plus souvent 44 100. baseLatency et outputLatency varient selon la plateforme, et latencyHint est une demande que le navigateur peut refuser d'honorer.
Le côté traitement a bougé lui aussi. AudioWorklet exécute le code de traitement sur un thread Web Audio séparé, et process() est appelé une fois par bloc audio ; les blocs font actuellement toujours 128 images, même si la taille est censée être relue à chaque fois. Il a remplacé ScriptProcessorNode, qui tournait sur le thread principal.
La conclusion est directe : ni l'horloge de l'affichage ni celle de l'audio ne vous donnent 60,0988. Quelle que soit celle que vous prenez comme source de tempo, un écart de rattrapage subsiste face à l'autre, et il vous faut un endroit où le porter.
Rendre le cœur interchangeable
Kaset propose deux familles de cœurs : TetaNES (natif) et Libretro. Le choix voyage dans la clé kaset.core du localStorage et dans un paramètre ?core=, et l'interface indique qu'un changement prend effet à la cartouche suivante.
Cette mention de « cartouche suivante » n'est pas un compromis d'ergonomie ; c'est la forme du contrat. libretro est une interface légère, en C, qui expose les rappels audio, vidéo et entrée sous une forme générique, et sa version d'API est encore la 1. retro_load_game charge le contenu, chaque appel à retro_run produit exactement une image vidéo, et le frontend apprend les caractéristiques audio et vidéo via retro_get_system_av_info. Changer de cœur n'est pas basculer un drapeau dans un processus en cours ; c'est remonter le contrat depuis le début.
Le chemin natif est en Rust. TetaNES est un émulateur NES multiplateforme qui tourne aussi dans le navigateur via WebAssembly, réparti sur deux crates, avec tetanes-core comme bibliothèque d'émulation indépendante de toute interface. C'est cette séparation qui nous permet de poser notre propre couche de rendu par-dessus. La cible de compilation est wasm32-unknown-unknown, la cible WebAssembly la plus dépouillée — elle n'importe aucune fonction de l'hôte — et elle se situe au niveau 2. Au 11 août 2026, la dernière version publiée de tetanes-core est la 0.15.0, datée du 7 août 2026, sous licence MIT ou Apache-2.0.
Le chemin libretro passe par Nostalgist.js. La bibliothèque n'embarque aucun émulateur ; elle pilote les cœurs de RetroArch compilés avec Emscripten, et son unique point d'entrée est launch({ core, rom }). L'option core accepte soit un nom connu, soit un objet { name, js, wasm }, et c'est là que la liste reste ouverte. C'est aussi la provenance de l'étiquette NESTOPIA CORE dans la barre du haut : Nestopia est un émulateur NES et Famicom précis au cycle près, le portage libretro s'appuie sur le fork amont Nestopia JG, et il est sous licence GPLv2.
Les deux chemins restent séparés dans le bundle et se chargent comme leurs propres morceaux : nativeWorkerEngine, nativeEngine, nostalgistEngine, crtParams et pacing. Une version de la même séparation est apparue dans la note « La couche de serving des LLM locaux : vLLM, SGLang, llama.cpp et Ollama » — là aussi, choisir un environnement d'exécution et préserver la portabilité de ce choix étaient deux travaux distincts.
Passer à un worker, et ce que coûte l'isolation
Sortir le rendu du thread principal se heurte à un test de capacités dès la porte. Kaset exige les cinq conditions ; si tout est présent, il charge le moteur fondé sur le worker, sinon celui du thread principal.
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4) Ces cinq conditions forment en réalité deux chaînes. Sur la chaîne canvas, transferControlToOffscreen confie le contrôle du rendu à un objet OffscreenCanvas ; l'élément de la page devient un substitut, sa taille intrinsèque se fige, et il ne peut plus obtenir de contexte de dessin propre. Le transfert est à sens unique et unique tout court — l'appeler sur un canvas qui possède déjà un contexte, ou qui a déjà été transféré, lève InvalidStateError.
La chaîne de mémoire partagée demande davantage. SharedArrayBuffer exige que le document soit dans un contexte sécurisé et isolé cross-origin, et l'isolation s'active par deux en-têtes du serveur : Cross-Origin-Opener-Policy: same-origin et Cross-Origin-Embedder-Policy: require-corp. Le résultat se lit dans le code via self.crossOriginIsolated. En contrepartie, les objets SharedArrayBuffer peuvent voyager par postMessage et Performance.now offre une résolution plus fine.
Le coût est tout aussi net. COOP same-origin signifie que le document ne partage son groupe de contexte de navigation qu'avec des documents de même origine ; sous require-corp, les ressources récupérées en mode no-cors doivent être de même origine ou accorder explicitement la permission via Cross-Origin-Resource-Policy. Si vous avez l'habitude de déposer des scripts tiers sur la page, ce choix vous demande de faire le ménage d'abord.
Une hypothèse mérite d'être corrigée : passer à un worker ne signifie pas renoncer à l'horloge de l'affichage. requestAnimationFrame existe aussi dans les workers dédiés (dans la Baseline depuis mars 2023), à condition que le worker ait une fenêtre propriétaire. Atomics.wait, en revanche, ne peut pas être utilisé sur le thread principal et ne fonctionne que sur des tableaux adossés à SharedArrayBuffer — donner le tempo en garant un thread n'est disponible que du côté worker.
Le composite n'est pas un look, c'est le support
La vraie décision de la couche vidéo est conceptuelle plutôt que technique. Le PPU de la NES ne produit pas du RGB pour le convertir ensuite en composite ; il construit directement de la vidéo NTSC dans le domaine composite. La vidéo composite n'est pas un filtre ajouté après coup — c'est le signal lui-même.
La palette rend cela concret. Une valeur sur six bits correspond à l'une de 64 sorties, où les deux bits de poids fort règlent la luminosité et les quatre bits de poids faible règlent l'essentiel de la teinte. La teinte est ici une phase de sous-porteuse : les valeurs de $x1 à $xC sont une onde carrée oscillant entre deux niveaux de tension. La couleur voyage comme du temps, pas comme un nombre.
Une conséquence en découle directement : il n'existe pas de palette correcte unique. Sur du matériel réel, la palette a au moins quatre sources de variation — l'adaptation d'impédance, les réglages du téléviseur par l'utilisateur, la façon dont il décode le composite en RGB, et l'espace colorimétrique propre au poste. Selon les mots de NESdev, aucune palette composite unique ne satisfait à la fois l'apparence voulue de tous les jeux, et Nintendo n'a jamais décrit de moniteur de référence à ses développeurs sous licence. C'est précisément pour cela que nous proposons quatre profils plutôt qu'un.
La résolution de couleur inférieure à la résolution de pixel vient du même endroit. Un cycle de couleur dure douze battements alors qu'un pixel NTSC fait huit battements de large, si bien qu'une partie de l'information de couleur est partagée avec le pixel voisin. Comme une ligne de balayage porte 227⅓ cycles de couleur, l'alignement se décale à chaque ligne et un motif se répète toutes les trois lignes — visible sous forme de scintillement lors des défilements lents.
Les quatre profils reposent sur ces faits. La façon dont le composite est décodé varie d'un poste à l'autre, et certains postes ne filtrent pas du tout — c'est de là que vient Living Room TV. Un tube Trinitron utilise un seul canon à électrons, un phosphore en bandes et une grille d'ouverture comme sélecteur de couleur, la grille étant faite de bandes formées par des fentes verticales dans une fine feuille. Un masque à fentes est au contraire une plaque perforée qui masque des triades de phosphore. Les deux laissent des structures visibles différentes : l'une une triade de points, l'autre une ligne verticale continue. C'est pourquoi le réglage du masque dans nos paramètres porte un champ kind.
Le profil RF repose sur la bande passante. Un canal de télévision fait 6 MHz de large au total, tandis que les canaux de différence de couleur voyagent entre quelques centaines de kHz et 1,3 MHz — la couleur circule dans une bande bien plus étroite que la luminance. Les lignes de balayage sont visibles pour une autre raison : la NES produit toujours 262 lignes, si bien que le téléviseur dessine les trames les unes sur les autres et qu'aucune image entrelacée ne se forme.
Les nombres derrière les profils sont des valeurs que nous avons choisies ; ils ne viennent pas d'une mesure d'étalonnage. Dans la couche couleur : saturation 1,25, contraste 1,06, gamma 1,05 et un multiplicateur de teinte légèrement chaud ; dans la couche des lignes de balayage : une largeur de faisceau autour de 0,55 ; dans la couche du masque : un type ombre à 0,25 d'intensité et une échelle de 3. La couche CRT en WebGL n'est activée que sur le cœur natif.
Région, sauvegardes d'état, et où reste le fichier
La région n'est pas une étiquette, c'est une machine différente. Une image PAL fait 312 lignes à 50,0070 Hz, avec un rapport de forme des pixels de 1,386:1 contre 1,143:1 en NTSC. Le motif d'artefacts de couleur change aussi : une ligne PAL porte 284⅙ cycles de chrominance, si bien que le motif se répète toutes les six lignes plutôt que toutes les trois.
Il existe un hybride entre les deux. La Dendy est un clone de Famicom qui utilise un signal PAL alors que son CPU tourne à la vitesse d'un NTSC, combinant la géométrie d'image PAL avec le rapport CPU/PPU du NTSC. Les cycles CPU par image donnent trois nombres différents selon les trois systèmes : 29 780⅔ en NTSC, 33 247,5 en PAL et 35 464 sur Dendy. Kaset propose Auto, NTSC et PAL dans l'interface, et dans le code PAL et Dendy renvoient au même côté tandis que tout le reste renvoie à NTSC.
Du côté des sauvegardes d'état, un état est lié à la ROM : la sortie de saveState est stockée avec un romHash, un emplacement, le nom du fichier et une heure de création, et l'opération s'exécute dans une enveloppe de délai de cinq secondes. Lier une sauvegarde à une empreinte du contenu plutôt qu'à un nom de fichier est une question d'identité — pour que différentes copies du même jeu n'atterrissent pas dans l'emplacement l'une de l'autre.
Ce qui reste à la fin ramène à la première phrase. Le fichier que vous déposez dans Kaset ne quitte pas votre appareil ; le jeu tourne sur votre machine. Chaque décision de cette note est une forme différente de la même question : savoir exactement où le travail se passe.
Sources
- NESdev Wiki — Clock rate, Cycle reference chart (master clock, CPU and PPU divisors, cycles per frame)
- NESdev Wiki — PPU frame timing, PPU rendering (the dot skipped on odd frames and where the skip happens)
- NESdev Wiki — NTSC video, PAL video (colour generator, colour cycle width, chroma cycles per line, 240p)
- NESdev Wiki — PPU palettes (the six-bit palette value, hue as subcarrier phase, sources of palette variation)
- NESdev Wiki — Detect TV system, iNES, NES 2.0 (Dendy, CPU cycles per frame, region fields in the header)
- MDN Web Docs — SharedArrayBuffer, Window and WorkerGlobalScope: crossOriginIsolated
- MDN Web Docs — Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers
- MDN Web Docs — OffscreenCanvas, HTMLCanvasElement: transferControlToOffscreen(), Atomics.wait()
- MDN Web Docs — Window and DedicatedWorkerGlobalScope: requestAnimationFrame()
- MDN Web Docs — AudioWorklet, AudioWorkletProcessor: process(), AudioContext baseLatency and outputLatency, BaseAudioContext: sampleRate
- WHATWG HTML Standard — The canvas element (placeholder canvas behaviour)
- W3C Web Audio API — BaseAudioContext.currentTime (the audio stream's own time)
- lukexor/tetanes — repository and README; crates.io — tetanes-core release list
- Nostalgist.js documentation — Under the hood and launch
- libretro documentation — Developing Cores and the Nestopia UE core; libretro-common — libretro.h
- Rust compiler book — platform support: wasm32-unknown-unknown
- Sony US5382871A and US6111349A (aperture grille), Zenith EP0239083A2 (shadow mask)
- 47 CFR 73.682 — TV transmission standards (channel width and the colour-difference band)