← Alle Beiträge

ARCHITEKTUR

NES im Browser: nicht 60 Bilder, sondern 60,0988

Die NES-Hardware erzeugt 60,0988 Bilder pro Sekunde; der Browser zeichnet im Takt des Bildschirms. Die Entscheidungen, die wir beim Bau von Kaset zwischen diesen beiden Uhren getroffen haben: den Kern austauschbar halten, das Zeichnen aus dem Hauptthread holen und das Composite-Signal als das Medium behandeln, für das die Spiele entworfen wurden.

Die erste Zahl, die Ihnen beim Bau eines NES-Players für den Browser begegnet, ist keine runde Zahl. Die Konsole erzeugt nicht 60 Bilder pro Sekunde; das NTSC-NES läuft mit 60,0988 Hz. Der Bildschirm wiederum zeichnet in seinem eigenen Takt.

Kaset ist ein NES-Player, der im Browser läuft: Sie ziehen eine .nes-Datei auf das Fenster, das Spiel läuft auf Ihrem Gerät, und die Datei geht nie an einen Server. Dieser Beitrag hält die Entscheidungen fest, die wir beim Bau getroffen haben — den Kern austauschbar halten, das Zeichnen aus dem Hauptthread holen und Composite-Video nicht als Stilfrage behandeln, sondern als das Medium, für das die Spiele entworfen wurden. Die ganze Kette beginnt bei diesen vier Stellen nach dem Komma.

Eine Zahl: 60,0988

Der Unterschied lässt sich leicht greifbar machen. 0,0988 Bilder pro Sekunde ergeben rund 356 Bilder pro Stunde, ein Verhältnis von etwa 0,165 %. Anders gesagt: Etwa alle zehn Sekunden erzeugt das NES ein Bild mehr, als eine Darstellung mit 60 Hz zeigen kann.

Dieser Überschuss muss irgendwo verbucht werden. Verwerfen Sie das Bild, stockt die Bewegung für einen Augenblick; sammeln Sie ihn auf der Audioseite an, laufen Bild und Ton auseinander. Alle folgenden Entscheidungen kreisen um dieselbe Frage: wohin diese Differenz verbucht wird.

Woher der Takt kommt

Die Zahl ist nicht gewählt, sie ergibt sich aus einer Division. Bei NTSC muss der Haupttakt das Sechsfache des Farbhilfsträgers betragen, und diese Vorgabe erzeugt zwei eigenwillige Werte: Der Haupttakt ist definitionsgemäss 236,25 MHz geteilt durch elf, also 21,477272 MHz. Die PPU verbraucht vier dieser Takte für jeden Punkt, die CPU zwölf — deshalb fallen bei NTSC genau drei PPU-Punkte in einen CPU-Zyklus.

text
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

Auch die Bildgeometrie ist keine Entwurfsentscheidung, sie kommt direkt aus dem Signal. Von den 262 Zeilen sind 240 sichtbar, eine ist die Post-Render-Zeile, zwanzig entfallen auf die vertikale Austastung und eine ist die Pre-Render-Zeile. Bei PAL läuft dieselbe Kette über einen Haupttakt von 26,6017125 MHz und 312 Zeilen; dort fallen 3,2 PPU-Punkte in einen CPU-Zyklus.

Ein Punkt kürzer bei ungeraden Bildern

Das Detail, das die letzten Stellen festlegt, sitzt hier. Bei aktiviertem Rendering ist jedes ungeradzahlige PPU-Bild einen PPU-Takt kürzer als üblich; der Sprung führt direkt von Punkt (339,261) auf (0,0), ausgelassen wird also der Punkt (340,261). Bei abgeschaltetem Rendering gibt es keinen solchen Sprung, und jedes Bild läuft die vollen 89.342 Takte.

Die Rechnung folgt daraus. Wenn die Bilder abwechselnd 89.342 und 89.341 Takte dauern, liegt der Mittelwert bei 89.341,5; teilt man die Punktrate durch diesen Wert, ergeben sich 60,09881 Hz — ohne den Sprung wären es 60,09848. NESdev veröffentlicht in seiner Tabelle einen einzigen Wert: 60,0988. Was diese letzten Stellen auseinanderhält, ist ein einziger Takt, der bei jedem zweiten Bild entfällt.

Auf der Seite von Kaset wird der Bildtakt gemessen: Der Wert effectiveFps im Objekt frameTiming wird gegen 60 bewertet, mit zwei getrennten Schwellen für die Abweichung. Die 0,0988 der Konsole liegen weit unter beiden — die Messung trägt die Abweichung der Hardware mit, ohne sie als Rauschen zu werten.

Welche Uhr den Takt vorgibt

Der Browser bietet zwei Zeitquellen, und keine davon ist die der Konsole. Auf der Bildseite führt requestAnimationFrame den Callback vor dem nächsten Neuzeichnen aus; die Aufrufrate entspricht meist der Bildwiederholrate des Bildschirms, und in Hintergrund-Tabs pausieren die meisten Browser den Aufruf. Auf der Audioseite ist die Web-Audio-Spezifikation deutlich: Die auf currentTime verstrichene Zeit gehört dem Audiostrom und läuft nicht zwingend synchron mit anderen Uhren im System.

Auch die Audiouhr verschiebt sich von Gerät zu Gerät. Wird keine Option angegeben, gilt die Abtastrate, die das Ausgabegerät bevorzugt — typisch zwischen 8.000 und 96.000 Hz, am häufigsten 44.100. baseLatency und outputLatency hängen von der Plattform ab, und latencyHint ist ein Wunsch, dem der Browser folgen kann, aber nicht muss.

Auch die Verarbeitungsseite hat sich verschoben. AudioWorklet führt den Verarbeitungscode in einem eigenen Web-Audio-Thread aus, und process() wird einmal pro Audioblock aufgerufen; die Blöcke sind derzeit immer 128 Frames lang, die Grösse sollte aber jedes Mal frisch gelesen werden. Damit ist ScriptProcessorNode abgelöst, der im Hauptthread lief.

Der Schluss daraus ist einfach: Weder die Bildschirmuhr noch die Audiouhr liefert 60,0988. Welche von beiden Sie zur Taktquelle machen: Gegenüber der anderen bleibt eine Verrechnungsdifferenz, und die müssen Sie irgendwo unterbringen.

Den Kern austauschbar halten

Kaset bietet zwei Kernfamilien: TetaNES (Nativ) und Libretro. Die Auswahl wird im Schlüssel kaset.core in localStorage und im Parameter ?core= mitgeführt, und die Oberfläche weist darauf hin, dass eine Änderung bei der nächsten Kassette greift.

Dieser Hinweis auf „die nächste Kassette“ ist keine Frage der Bedienung, sondern die Form des Vertrags. libretro ist eine schlanke, C-basierte Schnittstelle, die Audio-, Video- und Eingabe-Callbacks in generischer Form nach aussen führt; die API-Version steht weiterhin bei 1. retro_load_game lädt den Inhalt, jeder Aufruf von retro_run erzeugt genau ein Videobild, und das Frontend erfährt die Audio- und Videoeigenschaften über retro_get_system_av_info. Einen Kern zu tauschen heisst nicht, in einem laufenden Prozess eine Flagge umzulegen; es heisst, den Vertrag von Anfang an neu aufzustellen.

Der native Weg führt über Rust. TetaNES ist ein plattformübergreifender NES-Emulator, der über WebAssembly auch im Browser läuft; er ist auf zwei Crates aufgeteilt, und tetanes-core ist eine Emulationsbibliothek unabhängig von jeder Oberfläche. Genau diese Trennung erlaubt es uns, unsere eigene Rendering-Schicht darüberzulegen. Das Build-Ziel ist wasm32-unknown-unknown, das schlankeste WebAssembly-Ziel — es importiert keine Funktionen vom Host — und liegt in Tier 2. Stand 11. August 2026 ist die neueste veröffentlichte Version von tetanes-core 0.15.0 vom 7. August 2026, lizenziert unter MIT oder Apache-2.0.

Der Libretro-Weg führt über Nostalgist.js. Die Bibliothek bringt keinen eigenen Emulator mit; sie steuert die mit Emscripten kompilierten Kerne von RetroArch, und ihr einziger Einstiegspunkt ist launch({ core, rom }). Die Option core nimmt entweder einen bekannten Namen oder ein Objekt { name, js, wasm } entgegen — an dieser Stelle bleibt die Liste der Kerne offen. Daher stammt auch die Beschriftung NESTOPIA-KERN in der oberen Leiste: Nestopia ist ein zyklengenauer NES- und Famicom-Emulator, der Libretro-Port baut auf dem Upstream-Fork Nestopia JG auf, lizenziert unter GPLv2.

Die beiden Wege liegen im Bundle getrennt und laden als eigene Chunks: nativeWorkerEngine, nativeEngine, nostalgistEngine, crtParams und pacing. Eine Variante derselben Trennung kam schon im Beitrag „Die Serving-Schicht für lokale LLMs: vLLM, SGLang, llama.cpp und Ollama“ vor — auch dort waren die Wahl einer Laufzeitumgebung und der Erhalt der Übertragbarkeit dieser Wahl zwei verschiedene Arbeiten.

Der Umzug in den Worker und was die Isolation kostet

Wer das Rendering aus dem Hauptthread nehmen will, stösst zuerst auf eine Prüfung der Browser-Fähigkeiten. Kaset verlangt alle fünf Bedingungen; sind sie vollständig vorhanden, lädt es die Worker-Engine, sonst die Engine im Hauptthread.

js
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4)

Diese fünf sind eigentlich zwei Ketten. In der Canvas-Kette übergibt transferControlToOffscreen die Zeichenkontrolle an ein OffscreenCanvas-Objekt; das Element auf der Seite wird zum Platzhalter, seine intrinsische Grösse steht damit fest, und es kann selbst keinen Zeichenkontext mehr erhalten. Die Übergabe läuft in eine Richtung und nur einmal — auf einem Canvas, das bereits einen Kontext hat oder schon übergeben wurde, löst der Aufruf einen InvalidStateError aus.

Die Kette für gemeinsam genutzten Speicher verlangt mehr. SharedArrayBuffer setzt voraus, dass das Dokument in einem sicheren Kontext läuft und cross-origin isolated ist; die Isolation schaltet der Server über zwei Header ein — Cross-Origin-Opener-Policy: same-origin und Cross-Origin-Embedder-Policy: require-corp. Im Code liest sich das Ergebnis als self.crossOriginIsolated. Dafür lassen sich SharedArrayBuffer-Objekte über postMessage weitergeben, und Performance.now liefert eine feinere Auflösung.

Der Preis dafür ist ebenso klar. COOP same-origin bedeutet, dass das Dokument seine Browsing-Context-Gruppe nur mit Dokumenten desselben Ursprungs teilt; unter require-corp müssen Ressourcen, die im no-cors-Modus geladen werden, entweder vom selben Ursprung stammen oder über Cross-Origin-Resource-Policy ausdrücklich zustimmen. Wenn Sie es gewohnt sind, Skripte von Dritten einfach einzubinden, verlangt diese Entscheidung von Ihnen zuerst eine Neuordnung.

Eine verbreitete Annahme lohnt die Richtigstellung: Der Umzug in den Worker bedeutet nicht, auf die Bildschirmuhr zu verzichten. requestAnimationFrame gibt es auch in Dedicated Workers (Baseline seit März 2023), sofern der Worker einem Fenster zugeordnet ist. Atomics.wait wiederum lässt sich im Hauptthread nicht verwenden und arbeitet nur mit Arrays, die auf einem SharedArrayBuffer liegen — den Takt dadurch zu halten, dass ein Thread wartend geparkt wird, ist allein auf der Worker-Seite möglich.

Composite ist keine Stilfrage, sondern das Medium

Die eigentliche Entscheidung in der Videoschicht ist begrifflicher und nicht technischer Natur. Die PPU des NES erzeugt kein RGB, das anschliessend nach Composite gewandelt wird; sie baut das NTSC-Video direkt im Composite-Bereich auf. Composite-Video ist kein nachträglich aufgelegter Filter — es ist das Signal selbst.

Die Palette macht das greifbar. Ein Sechs-Bit-Wert bildet auf einen von 64 Ausgängen ab; die oberen zwei Bit bestimmen die Helligkeit, die unteren vier weitgehend den Farbton. Der Farbton ist dabei eine Hilfsträgerphase: Die Werte von $x1 bis $xC bilden eine Rechteckschwingung, die zwischen zwei Spannungspegeln wechselt. Farbe reist als Zeitinformation, nicht als Zahl.

Daraus folgt unmittelbar: Die eine richtige Palette gibt es nicht. Auf echter Hardware hat die Palette mindestens vier Quellen der Variation — die Impedanzanpassung, die Benutzereinstellungen des Fernsehers, die Art, wie er Composite nach RGB dekodiert, und den Farbraum des Geräts selbst. In den Worten von NESdev erfüllt keine einzelne Composite-Palette gleichzeitig die beabsichtigte Darstellung jedes Spiels, und Nintendo hat seinen lizenzierten Entwicklern nie einen Referenzmonitor beschrieben. Genau deshalb bieten wir vier Profile an und nicht eines.

Dass die Farbauflösung unter der Pixelauflösung liegt, hat denselben Ursprung. Ein Farbzyklus dauert zwölf Takte, ein NTSC-Pixel ist acht Takte breit — ein Teil der Farbinformation wird also mit dem Nachbarpixel geteilt. Da eine Bildzeile 227⅓ Farbzyklen trägt, verschiebt sich die Ausrichtung in jeder Zeile, und alle drei Zeilen wiederholt sich ein Muster — bei langsamem Scrollen als Flimmern sichtbar.

Auf diesen Tatsachen ruhen die vier Profile. Wie Composite dekodiert wird, unterscheidet sich von Gerät zu Gerät, und manche Geräte filtern überhaupt nicht — daher stammt das Profil „Living Room TV“. Eine Trinitron-Röhre nutzt eine einzige Elektronenkanone, Streifenphosphor und als Farbwähler ein Aperturgitter; das Gitter besteht aus Streifen, die durch senkrechte Schlitze in einem dünnen Blech entstehen. Eine Lochmaske ist dagegen eine perforierte Platte, die Phosphortripel abschattet. Beide hinterlassen unterschiedliche sichtbare Strukturen: einmal ein Punktetripel, einmal eine durchgehende senkrechte Linie. Deshalb trägt die Maskeneinstellung in unseren Parametern überhaupt ein Feld kind.

Das RF-Profil ruht auf der Bandbreite. Ein Fernsehkanal ist insgesamt 6 MHz breit, während die Farbdifferenzkanäle zwischen einigen hundert kHz und 1,3 MHz liegen — die Farbe wird in einem weit schmaleren Band übertragen als die Luminanz. Dass die Scanlines sichtbar sind, hat einen anderen Grund: Der NES erzeugt immer 262 Zeilen, also zeichnet der Fernseher die Halbbilder übereinander, und ein Zeilensprungbild entsteht nicht.

Die Zahlen hinter den Profilen sind von uns gewählte Werte; sie stammen nicht aus einer Kalibriermessung. In der Farbebene: Sättigung 1,25, Kontrast 1,06, Gamma 1,05 und ein leicht warmer Tint-Faktor; in der Scanline-Ebene eine Strahlbreite um 0,55; in der Maskenebene der Typ shadow mit Stärke 0,25 und Skalierung 3. Die WebGL-CRT-Ebene ist allein im nativen Kern aktiv.

Region, Spielstände und wo die Datei bleibt

Die Region ist kein Etikett, sie ist eine andere Maschine. Ein PAL-Bild umfasst 312 Zeilen bei 50,0070 Hz, das Pixel-Seitenverhältnis liegt bei 1,386:1 gegenüber 1,143:1 bei NTSC. Auch das Muster der Farbartefakte ändert sich: Eine PAL-Zeile trägt 284⅙ Chroma-Zyklen, das Muster wiederholt sich also alle sechs Zeilen statt alle drei.

Dazwischen steht ein Hybrid. Dendy ist ein Famiclone, der ein PAL-Signal nutzt, dessen CPU aber so schnell läuft wie bei NTSC; er verbindet die PAL-Bildgeometrie mit dem CPU-zu-PPU-Verhältnis von NTSC. Die CPU-Zyklen pro Bild ergeben über die drei Systeme drei verschiedene Zahlen: 29.780⅔ bei NTSC, 33.247,5 bei PAL und 35.464 bei Dendy. In der Oberfläche bietet Kaset Automatisch, NTSC und PAL an; im Code fallen PAL und Dendy auf dieselbe Seite, alles Übrige auf NTSC.

Auf der Seite der Spielstände ist ein Stand an das ROM gebunden: Die Ausgabe von saveState wird zusammen mit einem romHash, einem Slot, dem Dateinamen und einem Erstellungszeitpunkt abgelegt, und der Vorgang läuft in einem Wrapper mit fünf Sekunden Zeitlimit. Einen Spielstand an einen Hash des Inhalts zu binden statt an einen Dateinamen, ist eine Frage der Identität — damit verschiedene Kopien desselben Spiels nicht im Slot der jeweils anderen landen.

Was am Ende bleibt, führt zum ersten Satz zurück. Die Datei, die Sie in Kaset ablegen, verlässt Ihr Gerät nicht; das Spiel läuft auf Ihrer Maschine. Jede Entscheidung in diesem Beitrag ist eine andere Form derselben Frage: genau zu wissen, wo die Arbeit stattfindet.

Quellen

  • 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)

Weitere Beiträge

Sie haben ein System zu bauen — oder eines, das weiterkommen soll.

No deck needed. 20 minutes. The rest is up to you.