ARKITEKTUR
NES i webbläsaren: inte 60 bildrutor, 60,0988
NES-hårdvaran producerar 60,0988 bildrutor i sekunden; webbläsaren ritar i skärmens egen takt. Besluten vi tog mellan de två klockorna när vi byggde Kaset: att hålla kärnan utbytbar, att flytta renderingen ut ur huvudtråden och att behandla kompositsignalen som det medium spelen utformades för.
Det första talet du möter när du bygger en NES-spelare för webbläsaren är inget runt tal. Konsolen producerar inte 60 bildrutor i sekunden; NTSC-versionen av NES går på 60,0988 Hz. Skärmen å sin sida ritar i sin egen takt.
Kaset är en NES-spelare som körs i webbläsaren: du släpper en .nes-fil på fönstret, spelet körs på din enhet, och filen går aldrig till någon server. Den här texten samlar besluten vi tog när vi byggde den — att hålla kärnan utbytbar, att flytta renderingen ut ur huvudtråden och att behandla kompositvideo inte som en stilfråga utan som det medium spelen utformades för. Hela kedjan börjar i de fyra siffrorna efter kommatecknet.
Ett tal: 60,0988
Skillnaden är lätt att göra konkret. 0,0988 bildrutor i sekunden blir ungefär 356 bildrutor i timmen, ett förhållande på omkring 0,165 %. Uttryckt på ett annat sätt: ungefär var tionde sekund producerar NES en bildruta mer än vad en presentation på 60 Hz hinner visa.
Överskottet måste bokföras någonstans. Kastar du bildrutan stannar rörelsen upp ett ögonblick; samlar du det på ljudsidan glider bild och ljud isär. Alla beslut nedan kretsar kring samma fråga: var skillnaden ska bokföras.
Varifrån takten kommer
Talet är inte valt, det faller ut ur en division. I NTSC måste huvudklockan vara sex gånger färgbärvågen, och det kravet ger två egendomliga värden: huvudklockan är per definition 236,25 MHz delat med elva, alltså 21,477272 MHz. PPU:n förbrukar fyra sådana klockpulser per punkt och CPU:n tolv — därför ryms exakt tre PPU-punkter i en CPU-cykel i 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 Bildgeometrin är inte heller ett designval; den kommer rakt ur signalen. Av de 262 raderna är 240 synliga, en är post-render-raden, tjugo är vertikal släckning och en är pre-render. I PAL löper samma kedja från en huvudklocka på 26,6017125 MHz genom 312 rader, och där ryms 3,2 PPU-punkter i en CPU-cykel.
En punkt kortare på udda bildrutor
Detaljen som avgör de sista siffrorna sitter här. Med renderingen påslagen är varje udda PPU-bildruta en PPU-klockpuls kortare än normalt; hoppet görs genom att gå direkt från punkt (339,261) till (0,0), så punkten som uteblir är (340,261). Med renderingen avstängd sker inget hopp alls, och varje bildruta löper hela 89 342 klockpulser.
Räkningen följer av detta. När bildrutorna växlar mellan 89 342 och 89 341 klockpulser blir medelvärdet 89 341,5, och punktfrekvensen delad med det ger 60,09881 Hz; utan hoppet skulle det bli 60,09848. NESdev publicerar ett enda värde i sin tabell: 60,0988. Det som skiljer de sista siffrorna åt är en enda klockpuls som utgår varannan bildruta.
På Kasets sida mäts bildtakten: värdet effectiveFps på objektet frameTiming bedöms mot 60, med två separata trösklar för avvikelse. Konsolens egna 0,0988 ligger långt under båda — mätningen bär hårdvarans egen avvikelse vidare utan att räkna den som brus.
Vilken klocka som håller takten
Webbläsaren erbjuder två tidskällor, och ingen av dem är konsolens. På bildsidan kör requestAnimationFrame callbacken före nästa ommålning; anropsfrekvensen följer oftast skärmens uppdateringsfrekvens, och i bakgrundsflikar pausar de flesta webbläsare den. På ljudsidan är Web Audio-specifikationen tydlig: tiden som förflutit på currentTime tillhör ljudströmmen och går inte nödvändigtvis synkront med andra klockor i systemet.
Ljudklockan skiftar dessutom från enhet till enhet. Anges ingen inställning gäller den samplingsfrekvens som utenheten föredrar — typiskt mellan 8 000 och 96 000 Hz, oftast 44 100. baseLatency och outputLatency varierar med plattformen, och latencyHint är en önskan som webbläsaren kan följa men inte måste.
Bearbetningssidan har också flyttat. AudioWorklet kör bearbetningskoden i en egen Web Audio-tråd, och process() anropas en gång per ljudblock; blocken är för närvarande alltid 128 frames långa, men storleken är tänkt att läsas av på nytt varje gång. Den har ersatt ScriptProcessorNode, som körde i huvudtråden.
Slutsatsen är enkel: varken skärmklockan eller ljudklockan ger dig 60,0988. Vilken av dem du än gör till taktkälla kvarstår en avräkningsdifferens mot den andra, och den behöver du bära någonstans.
Att hålla kärnan utbytbar
Kaset erbjuder två kärnfamiljer: TetaNES (Nativ) och Libretro. Valet följer med i nyckeln kaset.core i localStorage och i parametern ?core=, och gränssnittet anger att en ändring gäller från nästa kassett.
Noteringen om ”nästa kassett” är ingen fråga om användbarhet; den är kontraktets form. libretro är ett lättviktigt, C-baserat gränssnitt som exponerar callbacks för ljud, video och inmatning i generisk form, och API-versionen står fortfarande på 1. retro_load_game laddar innehållet, varje anrop till retro_run producerar exakt en videobildruta, och frontenden får reda på ljud- och videoegenskaperna via retro_get_system_av_info. Att byta kärna är inte att slå om en flagga i en pågående process; det är att resa kontraktet på nytt från början.
Den nativa vägen går via Rust. TetaNES är en plattformsoberoende NES-emulator som via WebAssembly även körs i webbläsaren; den är uppdelad på två crates, och tetanes-core är ett emuleringsbibliotek oberoende av gränssnitt. Just den uppdelningen är det som låter oss lägga vårt eget renderingslager ovanpå. Byggmålet är wasm32-unknown-unknown, det mest avskalade WebAssembly-målet — det importerar inga funktioner från värden — och ligger i Tier 2. Den 11 augusti 2026 är den senaste publicerade versionen av tetanes-core 0.15.0, daterad 7 augusti 2026, licensierad under MIT eller Apache-2.0.
Libretro-vägen går via Nostalgist.js. Biblioteket har ingen egen emulator med sig; det driver RetroArchs kärnor kompilerade med Emscripten, och dess enda ingångspunkt är launch({ core, rom }). Alternativet core tar antingen ett känt namn eller ett objekt { name, js, wasm } — det är där listan hålls öppen. Därifrån kommer också etiketten NESTOPIA-KÄRNA i det övre fältet: Nestopia är en cykelexakt NES- och Famicom-emulator, libretro-porten bygger på uppströmsforken Nestopia JG, och den är licensierad under GPLv2.
De två vägarna ligger åtskilda i bundlen och laddas som egna chunks: nativeWorkerEngine, nativeEngine, nostalgistEngine, crtParams och pacing. En variant av samma uppdelning dök upp i texten ”Serving-lagret för lokala LLM: vLLM, SGLang, llama.cpp och Ollama” — även där var att välja en körtidsmiljö och att bevara valets portabilitet två skilda arbeten.
Flytten till en worker, och vad isoleringen kostar
Att lyfta ut renderingen ur huvudtråden möter ett test av webbläsarens förmågor redan vid dörren. Kaset kräver alla fem villkoren; finns allt på plats laddas den worker-baserade motorn, annars den som körs i huvudtråden.
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4) De fem är egentligen två kedjor. I canvas-kedjan lämnar transferControlToOffscreen över ritkontrollen till ett OffscreenCanvas-objekt; elementet på sidan blir en platshållare, dess inneboende storlek låses fast, och det kan inte längre få en egen ritkontext. Överlämningen går åt ett håll och sker en enda gång — anropas den på en canvas som redan har en kontext, eller som redan lämnats över, ger den InvalidStateError.
Kedjan för delat minne begär mer. SharedArrayBuffer förutsätter att dokumentet körs i en säker kontext och är cross-origin isolated; isoleringen slås på av två headers från servern — Cross-Origin-Opener-Policy: same-origin och Cross-Origin-Embedder-Policy: require-corp. I koden läses resultatet av som self.crossOriginIsolated. I gengäld kan SharedArrayBuffer-objekt skickas vidare med postMessage, och Performance.now ger finare upplösning.
Priset är lika tydligt. COOP same-origin innebär att dokumentet delar sin browsing context group enbart med dokument från samma ursprung; under require-corp måste resurser som hämtas i no-cors-läge antingen komma från samma ursprung eller uttryckligen ge sitt medgivande via Cross-Origin-Resource-Policy. Är du van att lägga in tredjepartsskript direkt på sidan ber det här valet dig att först se över vad sidan laddar in.
Ett vanligt antagande är värt att räta ut: flytten till en worker betyder inte att skärmklockan lämnas därhän. requestAnimationFrame finns även i dedicated workers (Baseline sedan mars 2023), förutsatt att workern har ett ägande fönster. Atomics.wait går å andra sidan inte att använda i huvudtråden och fungerar bara på arrayer som ligger på en SharedArrayBuffer — att hålla takten genom att parkera en tråd i väntan är möjligt enbart på worker-sidan.
Komposit är ingen stilfråga, det är mediet
Det egentliga beslutet i videolagret är begreppsligt snarare än tekniskt. PPU:n i NES producerar inte RGB som sedan omvandlas till komposit; den bygger NTSC-videon direkt i kompositdomänen. Kompositvideo är inget filter som lagts på i efterhand — den är signalen själv.
Paletten gör det konkret. Ett sexbitarsvärde avbildas på en av 64 utgångar, där de två översta bitarna sätter ljusstyrkan och de fyra nedersta i stor utsträckning nyansen. Nyansen är här en fas hos färgbärvågen: värdena från $x1 till $xC är en fyrkantvåg som pendlar mellan två spänningsnivåer. Färgen färdas som tidsinformation, inte som ett tal.
En sak följer direkt av detta: det finns ingen enda korrekt palett. På riktig hårdvara har paletten minst fyra källor till variation — impedansanpassningen, tv:ns användarinställningar, sättet den avkodar komposit till RGB på, och apparatens egen färgrymd. Med NESdevs egna ord uppfyller ingen enskild kompositpalett varje spels avsedda utseende samtidigt, och Nintendo beskrev aldrig någon referensmonitor för sina licensierade utvecklare. Det är just därför vi erbjuder fyra profiler i stället för en.
Att färgupplösningen ligger under pixelupplösningen kommer från samma håll. En färgcykel varar tolv klockpulser medan en NTSC-pixel är åtta klockpulser bred, så en del av färginformationen delas med grannpixeln. Eftersom en bildrad bär 227⅓ färgcykler förskjuts inpassningen på varje rad och ett mönster upprepas var tredje rad — synligt som ett skimmer vid långsam scrollning.
De fyra profilerna vilar på de här förhållandena. Hur komposit avkodas skiljer sig från apparat till apparat, och vissa apparater filtrerar inte alls — därifrån kommer profilen ”Living Room TV”. Ett Trinitron-rör använder en enda elektronkanon, randig fosfor och ett aperturgaller som färgväljare; gallret består av remsor som bildas av lodräta springor i en tunn plåt. En skuggmask är i stället en perforerad plåt som skuggar fosfortripletter. De två lämnar olika synliga strukturer: den ena en triplett av punkter, den andra en obruten lodrät linje. Det är därför inställningen för masken i våra parametrar över huvud taget bär ett fält kind.
RF-profilen vilar på bandbredden. En tv-kanal är totalt 6 MHz bred, medan färgdifferenskanalerna ligger mellan några hundra kHz och 1,3 MHz — färgen färdas i ett långt smalare band än luminansen. Att scanlines syns har en annan grund: NES producerar alltid 262 rader, så tv:n ritar delbilderna ovanpå varandra och någon linjesprångsbild uppstår inte.
Siffrorna bakom profilerna är värden vi har valt; de kommer inte från en kalibreringsmätning. I färglagret: mättnad 1,25, kontrast 1,06, gamma 1,05 och en lätt varm tint-faktor; i scanline-lagret en strålbredd kring 0,55; i masklagret typen shadow med styrkan 0,25 och skalan 3. WebGL-CRT-lagret är påslaget enbart på den nativa kärnan.
Region, sparade lägen och var filen stannar
Regionen är ingen etikett, den är en annan maskin. En PAL-bildruta omfattar 312 rader vid 50,0070 Hz, och pixelproportionen är 1,386:1 mot 1,143:1 i NTSC. Mönstret av färgartefakter ändras också: en PAL-rad bär 284⅙ krominanscykler, så mönstret upprepas var sjätte rad i stället för var tredje.
Däremellan står en hybrid. Dendy är en famiclone som använder PAL-signal men vars CPU går lika fort som i NTSC; den förenar PAL:s bildgeometri med NTSC:s förhållande mellan CPU och PPU. CPU-cyklerna per bildruta blir tre olika tal över de tre systemen: 29 780⅔ i NTSC, 33 247,5 i PAL och 35 464 i Dendy. I gränssnittet erbjuder Kaset Auto, NTSC och PAL; i koden faller PAL och Dendy på samma sida, allt övrigt på NTSC.
På sidan för sparade lägen är ett läge bundet till ROM:en: utdata från saveState lagras tillsammans med en romHash, en slot, filnamnet och en skapandetidpunkt, och operationen körs med en tidsgräns på fem sekunder. Att binda en sparning till en hash av innehållet i stället för till ett filnamn är en fråga om identitet — så att olika kopior av samma spel inte hamnar i varandras slot.
Det som återstår på slutet leder tillbaka till den första meningen. Filen du släpper i Kaset lämnar inte din enhet; spelet körs på din maskin. Varje beslut i den här texten är en annan form av samma fråga: att veta exakt var arbetet utförs.
Källor
- 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)