ARQUITECTURA
NES en el navegador: no 60 fotogramas, 60,0988
El hardware de la NES produce 60,0988 fotogramas por segundo; el navegador dibuja al ritmo de la pantalla. Las decisiones que tomamos entre esos dos relojes al construir Kaset: mantener el núcleo intercambiable, sacar el renderizado del hilo principal y tratar la señal compuesta como el medio para el que se diseñaron los juegos.
El primer número con el que te encuentras al construir un reproductor de NES para el navegador no es redondo. La consola no produce 60 fotogramas por segundo: la NES NTSC funciona a 60,0988 Hz. La pantalla, entretanto, dibuja a su propio ritmo.
Kaset es un reproductor de NES que funciona en el navegador: sueltas un archivo .nes sobre la ventana, el juego se ejecuta en tu dispositivo y el archivo nunca va a un servidor. Esta nota recoge las decisiones que tomamos al construirlo: mantener el núcleo intercambiable, sacar el renderizado del hilo principal y tratar el vídeo compuesto no como una opción estética, sino como el medio para el que se diseñaron los juegos. Toda la cadena empieza en esos cuatro dígitos que hay después de la coma.
Un número: 60,0988
La diferencia es fácil de hacer concreta. A 0,0988 fotogramas por segundo salen unos 356 fotogramas por hora, una proporción de alrededor del 0,165 %. Dicho de otro modo: cada diez segundos, más o menos, la NES produce un fotograma más de los que puede mostrar una presentación a 60 Hz.
Ese excedente hay que anotarlo en algún sitio. Si descartas el fotograma, el movimiento se detiene un instante; si lo acumulas en el lado del audio, la imagen y el sonido se separan. Todas las decisiones que siguen giran alrededor de la misma pregunta: dónde se anota esa diferencia.
De dónde viene el reloj
El número no se eligió: sale de una división. En NTSC el reloj maestro tiene que ser seis veces la subportadora de color, y ese requisito produce dos cifras nada redondas: el reloj maestro es, por definición, 236,25 MHz dividido entre once, es decir, 21,477272 MHz. La PPU gasta cuatro de esos pulsos en cada punto y la CPU gasta doce; por eso en NTSC caben exactamente tres puntos de PPU dentro de un ciclo de CPU.
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 geometría del fotograma tampoco es una decisión de diseño: sale directamente de la señal. De las 262 líneas, 240 son visibles, una es la línea de posrenderizado, veinte son de borrado vertical y una es la de prerrenderizado. En PAL la misma cadena parte de un reloj maestro de 26,6017125 MHz y pasa por 312 líneas; allí caben 3,2 puntos de PPU dentro de un ciclo de CPU.
Un punto más corto en los fotogramas impares
El detalle que fija los últimos dígitos está aquí. Con el renderizado activado, cada fotograma impar de la PPU dura un pulso de reloj menos de lo normal; el salto se hace pasando directamente del punto (339,261) al (0,0), así que el punto que se omite es el (340,261). Con el renderizado desactivado no hay salto alguno y cada fotograma recorre los 89.342 pulsos completos.
La aritmética sale sola. Cuando los fotogramas alternan entre 89.342 y 89.341 pulsos, la media es 89.341,5, y al dividir la frecuencia de puntos entre ese valor salen 60,09881 Hz; sin el salto serían 60,09848. NESdev publica un único valor en su tabla: 60,0988. Lo que separa esos últimos dígitos es un solo pulso de reloj que se omite cada dos fotogramas.
En el lado de Kaset se mide la cadencia de fotogramas: el valor effectiveFps del objeto frameTiming se evalúa frente a 60, con dos umbrales distintos para la desviación. El 0,0988 propio de la consola queda muy por debajo de los dos: la medición arrastra la desviación del hardware sin tomarla por ruido.
Qué reloj marca el compás
El navegador ofrece dos fuentes de tiempo, y ninguna es la de la consola. Del lado de la pantalla, requestAnimationFrame ejecuta el callback antes del siguiente repintado; la frecuencia de llamada suele coincidir con la frecuencia de refresco de la pantalla y, en las pestañas en segundo plano, la mayoría de los navegadores la pausan. Del lado del audio, la especificación de Web Audio es explícita: el tiempo transcurrido en currentTime pertenece al flujo de audio y puede no estar sincronizado con los demás relojes del sistema.
El reloj del audio, además, cambia de un dispositivo a otro. Cuando no se indica ninguna opción, la frecuencia de muestreo es la que prefiera el dispositivo de salida: normalmente entre 8.000 y 96.000 Hz, y lo más habitual 44.100. baseLatency y outputLatency varían según la plataforma, y latencyHint es una petición que el navegador puede no atender.
El lado del procesamiento también se movió. AudioWorklet ejecuta el código de procesamiento en un hilo de Web Audio aparte, y process() se llama una vez por bloque de audio; por ahora los bloques miden siempre 128 frames, aunque el tamaño está pensado para leerse de nuevo cada vez. Sustituyó a ScriptProcessorNode, que se ejecutaba en el hilo principal.
La conclusión es directa: ni el reloj de la pantalla ni el del audio te dan 60,0988. Elijas el que elijas como fuente del tempo, queda un desfase que conciliar con el otro, y necesitas un sitio donde llevarlo.
Mantener el núcleo intercambiable
Kaset ofrece dos familias de núcleos: TetaNES (Native) y Libretro. La elección viaja en la clave kaset.core de localStorage y en un parámetro ?core=, y la interfaz indica que un cambio surte efecto en el siguiente cartucho.
Esa nota del «siguiente cartucho» no es una concesión de usabilidad: es la forma del contrato. libretro es una interfaz ligera, basada en C, que expone los callbacks de audio, vídeo y entrada de forma genérica, y su versión de API sigue siendo la 1. retro_load_game carga el contenido, cada llamada a retro_run produce exactamente un fotograma de vídeo y el frontend conoce las características de audio y vídeo a través de retro_get_system_av_info. Cambiar de núcleo no es mover un flag dentro de un proceso en marcha: es levantar el contrato otra vez desde el principio.
El camino nativo es Rust. TetaNES es un emulador de NES multiplataforma que también funciona en el navegador a través de WebAssembly; está repartido en dos crates, y tetanes-core es una biblioteca de emulación independiente de cualquier interfaz. Esa separación es justo lo que nos permite poner encima nuestra propia capa de renderizado. El objetivo de compilación es wasm32-unknown-unknown, el más ligero de WebAssembly —sin importaciones del host—, y está en Tier 2. A 11 de agosto de 2026, la última versión publicada de tetanes-core es la 0.15.0, del 7 de agosto de 2026, con licencia MIT o Apache-2.0.
El camino libretro pasa por Nostalgist.js. La biblioteca no trae emulador propio: controla los núcleos de RetroArch compilados con Emscripten, y su único punto de entrada es launch({ core, rom }). La opción core acepta o bien un nombre conocido, o bien un objeto { name, js, wasm }, y ahí es donde la lista queda abierta. De ahí viene también la etiqueta NESTOPIA CORE de la barra superior: Nestopia es un emulador de NES y Famicom con precisión de ciclo, el port de libretro parte del fork upstream Nestopia JG y está bajo licencia GPLv2.
Los dos caminos están separados en el bundle y se cargan como chunks propios: nativeWorkerEngine, nativeEngine, nostalgistEngine, crtParams y pacing. Una variante de esa misma separación ya apareció en el artículo «La capa de serving para LLM locales: vLLM, SGLang, llama.cpp y Ollama»: allí también, elegir un entorno de ejecución y conservar la portabilidad de esa elección eran dos trabajos distintos.
Pasar a un worker y lo que cuesta el aislamiento
Sacar el renderizado del hilo principal se topa con una comprobación de capacidades en la puerta. Kaset exige las cinco condiciones; si están todas, carga el motor basado en worker y, si no, el del hilo principal.
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4) Esas cinco son en realidad dos cadenas. En la cadena del canvas, transferControlToOffscreen entrega el control del renderizado a un objeto OffscreenCanvas; el elemento de la página pasa a ser un marcador de posición, su tamaño intrínseco queda fijo y ya no puede obtener un contexto de dibujo propio. La transferencia es de un solo sentido y de una sola vez: llamarla sobre un canvas que ya tiene contexto, o que ya se ha transferido, lanza InvalidStateError.
La cadena de la memoria compartida pide más. SharedArrayBuffer exige que el documento esté en un contexto seguro y aislado entre orígenes, y ese aislamiento lo activan dos cabeceras que envía el servidor: Cross-Origin-Opener-Policy: same-origin y Cross-Origin-Embedder-Policy: require-corp. El resultado se lee en el código como self.crossOriginIsolated. A cambio, los objetos SharedArrayBuffer pueden viajar por postMessage y Performance.now ofrece una resolución más fina.
El coste está igual de claro. COOP same-origin significa que el documento comparte su grupo de contextos de navegación solo con documentos del mismo origen; con require-corp, los recursos que se piden en modo no-cors tienen que ser del mismo origen o conceder permiso de forma explícita mediante Cross-Origin-Resource-Policy. Si tienes la costumbre de ir soltando scripts de terceros en la página, esta decisión te pide hacer limpieza primero.
Conviene corregir una suposición extendida: pasar a un worker no significa renunciar al reloj de la pantalla. requestAnimationFrame también existe dentro de los dedicated workers (Baseline desde marzo de 2023), siempre que el worker tenga una ventana propietaria. Atomics.wait, en cambio, no puede usarse en el hilo principal y solo funciona con arrays respaldados por SharedArrayBuffer: marcar el tempo dejando un hilo en espera solo es posible del lado del worker.
El vídeo compuesto no es un estilo, es el medio
La decisión de verdad en la capa de vídeo es conceptual antes que técnica. La PPU de la NES no produce RGB para convertirlo después a compuesto: construye el vídeo NTSC directamente en el dominio compuesto. El vídeo compuesto no es un filtro que se añade después, es la señal misma.
La paleta lo hace concreto. Un valor de seis bits se asigna a una de 64 salidas: los dos bits altos fijan el brillo y los cuatro bajos fijan en buena medida el tono. El tono es aquí una fase de la subportadora: los valores de $x1 a $xC son una onda cuadrada que oscila entre dos niveles de tensión. El color viaja como temporización, no como número.
De ahí se sigue una consecuencia directa: no existe una única paleta correcta. En hardware real la paleta tiene al menos cuatro fuentes de variación: la adaptación de impedancias, los ajustes de usuario del televisor, la forma en que este decodifica el compuesto a RGB y el espacio de color del propio aparato. En palabras del propio NESdev, ninguna paleta compuesta satisface a la vez el aspecto buscado por todos los juegos, y Nintendo nunca describió un monitor de referencia a sus desarrolladores con licencia. Por eso justamente ofrecemos cuatro perfiles en lugar de uno.
Que la resolución de color quede por debajo de la resolución de píxel viene del mismo sitio. Un ciclo de color dura doce pulsos de reloj mientras que un píxel NTSC mide ocho, así que parte de la información de color se comparte con el píxel vecino. Como una línea de barrido lleva 227⅓ ciclos de color, la alineación se desplaza en cada línea y el patrón se repite cada tres líneas: se ve como un centelleo en los desplazamientos lentos.
Los cuatro perfiles se apoyan en esos hechos. La forma de decodificar el compuesto cambia de un televisor a otro, y algunos aparatos no filtran nada: de ahí sale «Living Room TV». Un tubo Trinitron usa un único cañón de electrones, fósforo en franjas y una rejilla de apertura como selector de color; la rejilla son tiras formadas por ranuras verticales en una lámina fina. Una máscara de sombra, en cambio, es una placa perforada que ensombrece tríadas de fósforo. Las dos dejan estructuras visibles distintas: una, una tríada de puntos; la otra, una línea vertical continua. Por eso el ajuste mask de nuestros parámetros lleva un campo kind.
El perfil RF se apoya en el ancho de banda. Un canal de televisión ocupa 6 MHz en total, mientras que los canales de diferencia de color viajan entre unos cientos de kHz y 1,3 MHz: el color va por una banda mucho más estrecha que la luminancia. Que las líneas de barrido se vean tiene otro motivo: la NES produce siempre 262 líneas, así que el televisor dibuja los campos unos sobre otros y no se forma imagen entrelazada.
Los números que hay detrás de los perfiles son valores que elegimos nosotros: no salen de una medición de calibración. En la capa de color, saturación 1,25, contraste 1,06, gamma 1,05 y un multiplicador de tinte ligeramente cálido; en la capa de líneas de barrido, un ancho de haz en torno a 0,55; en la capa de máscara, tipo shadow con intensidad 0,25 y escala 3. La capa CRT de WebGL solo se activa en el núcleo nativo.
Región, estados guardados y dónde se queda el archivo
La región no es una etiqueta, es otra máquina. Un fotograma PAL son 312 líneas a 50,0070 Hz, con una relación de aspecto de píxel de 1,386:1 frente a 1,143:1 en NTSC. El patrón de artefactos de color también cambia: una línea PAL lleva 284⅙ ciclos de croma, así que el patrón se repite cada seis líneas en vez de cada tres.
En medio hay un híbrido. Dendy es un famiclone que usa señal PAL pero cuya CPU va tan rápido como la de NTSC: combina la geometría de fotograma de PAL con la relación CPU/PPU de NTSC. Los ciclos de CPU por fotograma dan tres números distintos en los tres sistemas: 29.780⅔ en NTSC, 33.247,5 en PAL y 35.464 en Dendy. En la interfaz Kaset ofrece Auto, NTSC y PAL; en el código, PAL y Dendy se asignan al mismo lado y todo lo demás a NTSC.
En el lado de los estados guardados, el estado queda ligado a la ROM: la salida de saveState se guarda junto con un romHash, un slot, el nombre del archivo y una hora de creación, y la operación corre dentro de un envoltorio con un tiempo de espera de cinco segundos. Ligar un guardado al hash del contenido y no al nombre del archivo es una cuestión de identidad: para que distintas copias del mismo juego no acaben en el slot de la otra.
Lo que queda al final vuelve a la primera frase. El archivo que sueltas en Kaset no sale de tu dispositivo; el juego se ejecuta en tu máquina. Cada decisión de esta nota es una forma distinta de la misma pregunta: saber exactamente dónde está ocurriendo el trabajo.
Fuentes
- 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)