Tachibana

Astéroric › Artikel › Asteroids auf einen Oric-1 portieren: was sich gewehrt hat

Asteroids auf einen Oric-1 portieren: was sich gewehrt hat

Vierzig Phasen, um ein Vektorspiel von 1979 in 20.000 Zyklen pro Bild unterzubringen. Was nachgab, was sich wehrte, und was der Emulator vor uns verbarg.

Von bmarty · Astéroric 1.0.0-beta · 14. September 2026

Asteroids läuft auf einem Automaten mit Vektorbildschirm: ein Strahl, der Segmente zieht, keine Pixel, kein Bildspeicher. Der Oric-1 ist das genaue Gegenteil — eine Bitmap von 240 × 200 Punkten, ein 6502 mit 1 MHz, 48 KB, von denen ein Teil vom Bildschirm belegt ist, und ein Soundchip mit drei Rechteckkanälen. Das Projekt Astéroric ist ein Studienklon: die Arcade-Logik so übernehmen, wie sie im rev-4-ROM steht (derselbe Prozessor, das hilft), und sie auf dieser Maschine unterbringen. Dieser Artikel erzählt, was sich gewehrt hat. Die Phasennummern verweisen auf den CHANGELOG des Repositorys, der maßgeblich ist.

1. Das Budget, das nicht reichte

Die anfängliche Planung machte die Rechnung auf: bei 1 MHz und 50 Bildern pro Sekunde stehen 20.000 Zyklen pro Bild zur Verfügung. Eine korrekte 6502-Bresenham-Routine ohne selbstmodifizierenden Code kostet 15 bis 22 Zyklen pro Pixel; mit 30 Segmenten zu 20 Pixeln, gezeichnet und wieder gelöscht, ist das Budget aufgebraucht, bevor irgendetwas berechnet wurde. Das Dokument nannte drei Hebel: 25 Hz akzeptieren, die Formen vereinfachen oder massiv in die Linienroutine investieren.

Die Messung der Phase 2 entschied: ~97 Zyklen pro Pixel, das Fünffache der Annahme. Die 25 Hz wurden sofort festgelegt — als Nominalziel, nicht als Abstrich, und es ist die Bildrate vieler Oric-Spiele. Der selbstmodifizierende Code der Phase 2b (dx/dy-Operanden als Immediates gepatcht, Richtung des Y-Schritts beim Eintritt gepatcht) brachte nur 4 Zyklen pro Pixel, weit entfernt von den angestrebten 50. Die eigentliche Ersparnis kam später, und von anderswo.

2. Vektoren auf eine Bitmap zeichnen

Alles wird per XOR gezeichnet: ein Segment ein zweites Mal zu zeichnen löscht es, ohne den Hintergrund zu sichern. Das ist elegant und hat einen unmittelbaren Fehler, entdeckt in Phase 9g: ein Pixel, das zwei Segmente teilen — die Spitze des Schiffs, jeder Eckpunkt eines geschlossenen Polygons — wird zweimal ge-XOR-t, also gelöscht. Alle Eckpunkte der Asteroiden waren unsichtbar. Die erste Korrektur war ein explizites Replot der geteilten Eckpunkte (drei XOR = Pixel an). Die richtige Korrektur kam in Phase 24: ein halboffener Bresenham, der ]P0, P1] zeichnet, alle Pixel außer dem Startpunkt. Auf einem im Kreis durchlaufenen Polygon wird jeder Eckpunkt dann genau einmal ge-XOR-t. Nettokosten null — ein Pixel weniger pro Segment bezahlt den Eintrittstest.

Der entscheidende Leistungsgewinn kam nicht von der Linienroutine, sondern von der Idee, sie gar nicht mehr aufzurufen. Asteroiden drehen sich nie: ihre zwölf Silhouetten (vier Formen × drei Größen) werden beim Bauen von einem Python-Skript gerastert, das den Bresenham von line.s nachbildet, und als HIRES-Bitmaps ausgegeben, die in ihre sechs Phasen innerhalb eines Bytes vorverschoben sind (3.402 Bytes Daten). Das Rendern wird zu einem XOR-Blit Byte für Byte, ~23 Zyklen pro Nicht-Null-Byte. Ein Detail, das zählt: Da 240 durch 6 teilbar ist, behält die Geisterinstanz des Wraparound (±240 px) dieselbe Phase — ein einziger Satz Bitmaps. Die in Phase 18h erzwungene Dezimierung der Formen auf 6-7 Eckpunkte, um die Bildrate zu halten, konnte entfallen: Die feinen Kerben der Atari-Formen kamen zurück, weil die Kosten nicht mehr von der Zahl der Eckpunkte abhängen.

Eine Überraschung im Profiler, nebenbei: sizeof(Asteroid) = 13. Da das keine Zweierpotenz ist, zwang jeder Zugriff asteroids[i].feld cc65 zum Aufruf der Softwaremultiplikation mul8x167,1 % der CPU-Zeit. Iteration über Zeiger ließ sie aus dem Profil verschwinden. Insgesamt hoben die Phasen 24 bis 26 die freie CPU-Zeit von 17,9 % auf 39,8 %.

3. Die Zeit: der VSync, den es nicht gibt

Phase 9 synchronisierte die Schleife auf den CB1-Pin des VIA, der angeblich das VSync-Signal der ULA empfängt. Das Spiel lief unter Phosphoric einwandfrei. Dann richtete sich Phosphoric 1.16.11 an der Hardware aus, und das Spiel blieb endlos im Titelbildschirm hängen: Auf einem echten Oric ist CB1 nicht mit VSync verdrahtet. Der Emulator pulste den Pin der Bequemlichkeit halber und verdeckte den Fehler. Der Ersatz ist Timer 1 des VIA im Freilauf mit 20 ms — portabel auf echte Hardware, Phosphoric, Oricutron und MAME, um den Preis eines theoretischen Tearing, das nie beobachtet wurde.

Ein subtilerer Taktfehler blieb, seit Phase 18 unter dem Namen „effektiv 17 Hz in vollen Szenen“ bekannt. frame_wait() las den Zähler beim Eintritt und wartete dann zwei Ticks: Solange die Arbeit in den ersten Tick passte, ergab das Timerraster 40 ms pro Bild; sobald ein Bild 20 ms überschritt, wartete es erneut zwei volle Ticks — 60 ms statt 40. Phase 28 installierte einen Scheduler mit festem Schritt und persistentem Ziel (frame_target += 2), Aufholen über die folgenden Bilder und Resynchronisation nach einem langen Einfrieren. Adaptive 50 Hz wurden geprüft und verworfen: Die gesamte Physik rechnet in Pixeln pro Bild, rund fünfzehn Timerfamilien zählen in Bildern, und das UFO bewegt sich um ±1 Pixel — zeitweise auf 50 Hz zu wechseln würde die Spielgeschwindigkeit verdoppeln, es sei denn, alles würde neu skaliert. Konstante, verlässliche 25 Hz sind besser als sporadische, riskante 50 Hz.

4. Das Flimmern

Bei 25 Hz fehlt ein Objekt, das am Bildanfang gelöscht und am Bildende neu gezeichnet wird, ~80 % der Zeit auf dem Bildschirm: Das Auge sieht einen Da/Weg-Zyklus bei der schlechtestmöglichen Frequenz. Die Korrektur war strukturell — erase → tick → draw pro Entität zusammenrücken und alles, was kein Rendering ist (Sound, Punktestand, Texte), aus diesem Fenster hinausschieben. Für die Asteroiden schrumpfte das Fenster von etwa 20 ms auf 7 ms.

Die Torpedos zeigten eine tückischere Variante, aufgedeckt von einem Tester auf echter Hardware („fire beam hardly visible“). Die alte Pipeline löschte sie am Schleifenanfang und zeichnete sie am Ende neu: fast die gesamte Rechenzeit über nicht im Videospeicher. Auf echter Hardware driftet die Phase zwischen CRT-Abtastung und Timer 1, also erschienen sie sporadisch; unter Phosphoric ist die Phase verriegelt, und wir bekamen 0 von 20 Aufnahmen mit sichtbarem Torpedo — während das RAM ihn als aktiv auswies. Das Commit-Schema (an der Position der letzten Zeichnung löschen, unmittelbar danach neu zeichnen, Bildschirmzustand vom logischen Zustand entkoppelt) ergab 8 von 8 Aufnahmen. Die Torpedos wurden außerdem auf 2 × 2 px vergrößert.

5. Das ROM, das wir umgehen

Die Tastatur wird ohne den ROM-Puffer gelesen, direkt aus der Matrix — was voraussetzt, die Matrix verstanden zu haben. Erste Version falsch: Der Code prüfte verschiedene Spalten mit einer festen R14-Maske. Auf der Hardware liegen SPACE und die vier Pfeiltasten alle auf Spalte 4 und unterscheiden sich durch ihre Zeile, die über Register 14 des PSG gewählt wird. Nur die rechte SHIFT-Taste lieferte einen Fehlalarm. Zweite Falle, eigen für den 6522: ORB zu lesen liefert, was auf die als Ausgang konfigurierten Bits geschrieben wurde, nicht den Pinzustand. Hat das ROM DDRB = $FF hinterlassen, liest PB3 seinen eigenen Wert zurück, und die Abtastung wird zufällig — daher verpasste Schüsse und widersprüchliche Drehungen. key_scan erzwingt PB3 jetzt für die Dauer der Abtastung als Eingang.

Dritte Falle, gehört, bevor sie verstanden war: Um Port A des PSG im Matrixmodus anzusteuern, schrieb key_scan in jedem Bild R7 = $7F — „Mixer komplett stumm“. Die sechs Ton- und Rauschbits wurden einmal pro Bild abgeschnitten und blieben es bis zum nächsten geraden Soundtick: ein 25-Hz-Zerhacken aller Effekte, am deutlichsten bei den langen. Das war der „Audiofehler der Schiffsexplosion“ aus dem Backlog. Die Korrektur schreibt den letzten echten Mixerwert zurück, gehalten in einem mixer_shadow.

Der interruptgesteuerte Soundplayer (Phase 20) verlangte zu wissen, was das ROM tut, bevor es zum Benutzervektor springt: nichts. Der 6502 legt nur PC und P auf den Stack; die Sicherung von A, X und Y geschieht innerhalb des ROM-Handlers, den wir durch das Patchen des Vektors kurzschließen. Ohne eigene PHA/TXA/TYA holt das abschließende dreifache PLA P und die Rücksprungadresse vom Stack, und das RTI springt irgendwohin. Außerdem müssen alle anderen IRQ-Quellen des VIA deaktiviert werden, bevor T1 scharf geschaltet wird, sonst löst ein CA1/CB1/T2-Rest des ROM einen Handler aus, der ihn nie quittiert — Endlosschleife. Validiert unter Phosphoric mit --trace-irq: 493 Eintritte, 493 RTI, 19.930 bis 19.970 Zyklen zwischen zwei IRQs.

Der Handler hängt an beiden Vektoren, $0228 (Oric-1) und $0244 (Atmos). Das reichte nicht: Mit dem Atmos-ROM läuft das Spiel nicht, weder unter Phosphoric noch unter Oricutron. Die Atmos-Kompatibilität bleibt ein eigener Sprint, und die Website sagt das auch.

6. Die BSS, zweimal

C setzt voraus, dass eine statische Variable beim Start null ist; das crt0 muss das garantieren. Phase 9b: Mit einer BSS von mehr als $83 Bytes schaltete sich der HIRES-Bildschirm nicht mehr ein — das selbstgebaute zerobss unterschied sich subtil von dem von cc65, und die Lösung war, die offizielle Routine über initlib zu übernehmen. Phase 36, einen Monat später: Beim ersten Schuss erscheint ein Geisterblock an Position (85, 85). 85 ist $55, das Muster nicht initialisierten RAMs. Der Kommentar „initlib ruft zerobss auf“ war falsch — cc65 ruft sie getrennt auf — und die BSS wurde tatsächlich immer noch nicht gelöscht. Zwei Code-Reviews hatten einen Kommentar nicht nachgelesen.

7. Der Klang einer anderen Maschine

Der Automat von 1979 erzeugt seine Klänge mit diskreten analogen Oszillatoren; da gibt es nichts zu portieren. Der Ausgangspunkt war ein anderer 8-Bitter: Mine Storm, der Asteroids-Klon der Vectrex (1982). Anderer Prozessor (6809), aber derselbe AY-3-8912-Chip und derselbe VIA 6522 — der Code lässt sich nicht übertragen, die Registerwerte schon. Die Tabellen SS.THR, SS.BLT und SS.EXP dienten als erste Näherung.

Der Rest geschah nach Gehör und Spektrum: FFT-Analyse von Aufnahmen des Automaten. Die drei Explosionen (kleiner, mittlerer, großer Asteroid) sind ähnlich lang und unterscheiden sich nur in der Rauschfrequenz — ~167, 246 und 306 Hz, also R6 = 12, 8 und 6. Der Schuss wanderte von einem tiefen Rauschen auf ~740 Hz. Die Architektur folgte dem Automaten: Drei unabhängige Schaltungen werden zu drei eigenen Kanälen — A für die Effekte, B für das Thump-Thump, das keine Explosion je unterbricht, C für die Untertasse — mit einem Mixer, der aus dem Zustand aller drei neu berechnet wird. Der Titel-Jingle (Grieg, gemeinfrei) war drei Phasen lang unhörbar: Die Routine legte den Notenindex in sound_tmp ab und rief dann psg_write auf … das sound_tmp als Zwischenspeicher nutzt. Die zurückgelesene Note war der zuletzt an den PSG geschriebene Wert. Ein eigener Zwischenspeicher genügte, in der BSS untergebracht, weil die Zeropage bis aufs Byte voll war.

8. Der Emulator, der lügt, die Hardware, die antwortet

Astéroric wurde unter Phosphoric entwickelt, und die beiden Projekte haben einander korrigiert. Der Emulator verdeckte den nicht vorhandenen VSync (§3) und dann ein falsches IJK-Modell: Die erste Joystick-Abfrage lief über Register 14 des PSG, wie Phosphoric es simulierte, und funktionierte auf dem physischen Interface eines Testers nicht. Das echte Protokoll läuft über den Druckerport — VIA-Port A direkt, PB4-Strobe, Präsenzbit 5 — und wurde gegen Oricutron validiert; Phosphoric wurde parallel korrigiert, mit einem Nachspielen der exakten 6502-Sequenz des Spiels in seinen Tests.

Die Hardware offenbarte auch, was kein Emulator zeigen konnte. Das bei der Initialisierung geschriebene Videoattribut $1C wählt HIRES mit 60 Hz; Bit 1 wählt 50 Hz, und es braucht $1E. Der Inhalt des Videospeichers ist in beiden Fällen identisch, also auch der Screenshot — aber der RGB2HDMI des Testers rastete nicht auf das Signal ein. Ohne Folgen für die Spielgeschwindigkeit, die von Timer 1 und nicht von der Abtastung getaktet wird. Umgekehrt lieferte der Emulator die Instrumente: headless aufgenommene Bilder bitweise verglichen (make check), ein Profil über 25 Millionen Zyklen, IRQ-Tracing, symbolisierte RAM-Dumps, geskriptete --type-keys-Szenarien. Mit einer Falle: Ein geskripteter Tastendruck dauert 40 ms, genau ein Bild bei 25 Hz, und kann bei deterministischer Ausführung systematisch zwischen zwei Tastaturabtastungen fallen.

9. Was bleibt

Quellen & Links

← Astéroric · Funktionen →